Text Animationsv0.1.0
Flip Text
Letters flip vertically one by one on hover.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/flip-text.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/flip-text/flip-text.tsximport 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
| Prop | Type | Default | Description |
|---|---|---|---|
children* | string | – | Text to flip, split into letters. |
stagger | number | 24 | Milliseconds between letters. |
duration | number | 420 | Milliseconds each letter takes to flip. |
active | boolean | – | Hold the flipped state, e.g. while a tab is selected. |
flippedClassName | string | – | 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.