Skip to content

Flip Text

Letters flip vertically one by one on hover.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/flip-text.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/flip-text/flip-text.tsx
    import type { ComponentProps, CSSProperties } from "react";
    import { cn } from "@/lib/utils";
    
    export interface FlipTextProps extends Omit<ComponentProps<"span">, "children"> {
      /** Text to flip, split into letters. */
      children: string;
      /** Milliseconds between letters. @default 24 */
      stagger?: number;
      /** Milliseconds each letter takes to flip. @default 420 */
      duration?: number;
      /** Hold the flipped state, e.g. while a tab is selected. */
      active?: boolean;
      /** Classes for the incoming letters, e.g. a color change. */
      flippedClassName?: string;
    }
    
    // The outgoing copy rises out of a clipped cell while the incoming copy rises in from below.
    // Flips on hover/focus of the text itself, or of the closest ancestor with Tailwind's `group` class.
    const FLIP = cn(
      "transition-transform motion-reduce:transition-none",
      "group-hover/flip:translate-y-[calc(-100%_-_0.4em)] group-focus-visible/flip:translate-y-[calc(-100%_-_0.4em)]",
      "group-data-[active=true]/flip:translate-y-[calc(-100%_-_0.4em)]",
      "group-hover:translate-y-[calc(-100%_-_0.4em)] group-focus-visible:translate-y-[calc(-100%_-_0.4em)]",
    );
    
    function graphemes(value: string) {
      if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
        return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value), (s) => s.segment);
      }
      return Array.from(value);
    }
    
    /** Words become arrays of letters with a running index for the stagger; whitespace stays plain text so lines wrap. */
    function split(text: string) {
      const parts: (string | { char: string; index: number }[])[] = [];
      let index = 0;
      for (const token of text.split(/(\s+)/)) {
        if (!token) continue;
        if (/^\s+$/.test(token)) {
          parts.push(token);
          continue;
        }
        const letters = graphemes(token).map((char, i) => ({ char, index: index + i }));
        index += letters.length;
        parts.push(letters);
      }
      return parts;
    }
    
    export function FlipText({ children, stagger = 24, duration = 420, active, flippedClassName, className, ...props }: FlipTextProps) {
      const words = split(children);
    
      const timing = (i: number): CSSProperties => ({
        transitionDuration: `${duration}ms`,
        transitionDelay: `${i * stagger}ms`,
        transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)",
      });
    
      return (
        <span data-active={active || undefined} className={cn("group/flip inline-block", className)} {...props}>
          <span className="sr-only select-none">{children}</span>
          <span aria-hidden="true">
            {words.map((word, w) =>
              typeof word === "string" ? (
                word
              ) : (
                <span key={w} className="inline-block whitespace-nowrap">
                  {word.map(({ char, index: i }) => (
                    <span key={i} className="relative inline-block [clip-path:inset(-0.25em_-0.15em)]">
                      <span className={cn("inline-block", FLIP)} style={timing(i)}>
                        {char}
                      </span>
                      <span className={cn("absolute top-[calc(100%_+_0.4em)] left-0 block select-none", FLIP, flippedClassName)} style={timing(i)}>
                        {char}
                      </span>
                    </span>
                  ))}
                </span>
              ),
            )}
          </span>
        </span>
      );
    }

Usage

import Link from "next/link";
import { FlipText } from "@/components/ui/flip-text/flip-text";

export function NavLink() {
  return (
    <Link href="/work" className="group">
      <FlipText>Work</FlipText>
    </Link>
  );
}

API reference

PropTypeDefaultDescription
children*string–Text to flip, split into letters.
staggernumber24Milliseconds between letters.
durationnumber420Milliseconds each letter takes to flip.
activeboolean–Hold the flipped state, e.g. while a tab is selected.
flippedClassNamestring–Classes for the incoming letters, e.g. a color change.

Also accepts every native <span> attribute, including ref and className.

  • Works as a React Server Component; there is no state or effect.
  • Hover styles only apply on devices with a real hover, so touch taps don't leave the text stuck mid-flip.
  • `active` holds the flipped state, e.g. for a selected tab, and `flippedClassName` styles the incoming letters (a color change, for example).

Accessibility

  • Pure CSS with no JavaScript: it flips on hover of the text itself, or of the closest ancestor with Tailwind's group class, and on keyboard focus (focus-visible) of either.
  • Screen readers get the word once, from a visually hidden copy; the letter halves are aria-hidden.
  • With reduced motion enabled, the transition is removed: the text simply stays put.