# Flip roll

> Hover and the label rolls up into a fresh copy.

- Type: Component (cursor)
- Page: https://textlab.dev/components/flip-roll
- Markdown: https://textlab.dev/components/flip-roll.md
- Registry item: https://textlab.dev/r/flip-roll.json
- Source file: `registry/textlab/flip-roll/flip-roll.tsx`
- Dependencies: none
- Variants: stagger, up, down
- Keywords: roll, flip, hover, button text, nav link, text swap

## When to use

- Navigation links, buttons and footer links.
- Any short label that deserves a tactile hover.

## When not to use

- Long labels; the stagger tail drags.
- Inline links in body copy.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/flip-roll
pnpm dlx shadcn@latest add @textlab/flip-roll
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/flip-roll.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { FlipRoll } from "@/components/textlab/flip-roll/flip-roll";

export function Example() {
  return <a href="/contact" className="inline-flex"><FlipRoll text="Get in touch" /></a>;
}
```

## API reference

### FlipRoll

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"stagger" \| "up" \| "down"` | `"stagger"` | Roll letter by letter, or the whole label up or down. |
| `duration` | `number` | `0.6` | Seconds per roll. |
| `stagger` | `number` | `0.02` | Seconds between letters. |
| `ease` | `EaseName \| [number, number, number, number]` | `"emphasized"` | Easing token or cubic bezier. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Keyboard focus inside triggers it, through focus capture.
- Screen readers read the label once.

## Motion

- A long-tailed ease over 0.6s with a cross-fade, so it starts quick and lands soft.
- Leaving reverses immediately, interrupting cleanly.

## Reduced motion

Hover changes nothing.

## Performance

- CSS transitions on transform only.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: stagger, up, down.

## Related

- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.

## Also in cursor

- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.

## Source

### components/textlab/flip-roll/flip-roll.tsx

```tsx
"use client";

import type { CSSProperties, ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { cssEase, type EaseInput } from "../lib/motion-tokens";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";

export type FlipRollVariant = "up" | "down" | "stagger";

export interface FlipRollProps {
  text: string;
  /** up rolls the label up, down rolls it down, stagger rolls letter by letter. */
  variant?: FlipRollVariant;
  /** Seconds per roll. */
  duration?: number;
  /** Seconds between letters for the stagger variant. */
  stagger?: number;
  ease?: EaseInput;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * Hover and the label rolls up while a fresh copy rolls in behind it, like a drum turning one
 * notch. Also follows keyboard focus inside the element.
 */
export function FlipRoll({ text, variant = "stagger", duration = 0.6, stagger = 0.02, ease = "emphasized", as: Tag = "span", reducedMotion = "user", className, style }: FlipRollProps) {
  const { ref, on, bind } = useTrigger<HTMLElement>({ trigger: "hover" });
  const reduced = useReduced(reducedMotion);
  const chars = variant === "stagger" ? graphemes(text) : [text];
  const dir = variant === "down" ? -1 : 1;
  const flipped = on && !reduced;

  return (
    <Tag
      ref={ref}
      className={className}
      style={{ position: "relative", display: "inline-block", ...style }}
      onPointerEnter={bind.onPointerEnter}
      onPointerLeave={bind.onPointerLeave}
      onFocusCapture={bind.onFocus}
      onBlurCapture={bind.onBlur}
    >
      <span style={srOnly}>{text}</span>
      {/* The clip gets a little room above and below so descenders never touch its edge mid-roll. */}
      <span aria-hidden="true" style={{ display: "inline-flex", overflow: "hidden", verticalAlign: "top", lineHeight: 1.2, padding: "0.1em 0.02em", margin: "-0.1em 0" }}>
        {chars.map((char, i) => {
          const delay = variant === "stagger" ? i * stagger : 0;
          // Opacity eases a touch faster than the roll, so each copy is mostly gone before it reaches the edge.
          const t = `transform ${duration}s ${cssEase(ease)} ${delay}s, opacity ${duration * 0.7}s ${cssEase(ease)} ${delay}s`;
          const shift = (sign: number) => `translateY(calc(${sign * 100}% + ${sign * 0.2}em))`;
          return (
            <span key={i} style={{ position: "relative", display: "inline-block", whiteSpace: "pre" }}>
              <span style={{ display: "inline-block", transform: flipped ? shift(-dir) : "translateY(0)", opacity: flipped ? 0 : 1, transition: t, willChange: "transform" }}>{char}</span>
              <span style={{ position: "absolute", left: 0, top: 0, display: "inline-block", transform: flipped ? "translateY(0)" : shift(dir), opacity: flipped ? 1 : 0, transition: t, willChange: "transform" }}>{char}</span>
            </span>
          );
        })}
      </span>
    </Tag>
  );
}

export default FlipRoll;
```

Full library index: https://textlab.dev/llms.txt
