Skip to content
Corsair UI
Browse the docs

TextNo. 69 of 109

Roll Text

Characters that roll up to a copy of themselves on hover or keyboard focus.

Installation

pnpm dlx shadcn@latest add @corsair-ui/roll-text

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utils
npm
clsxtailwind-merge

Examples

The link has the group class, so hover and keyboard focus both roll it.

Slower, wider stagger

Hover the whole line

API

PropTypeDefault
children

The text.

string—
stagger / duration

In ms.

number20 / 400
as

The element.

"span" | "div" | "p" | "h1"…"h6""span"

Accessibility

  • Screen readers get the text once. It rolls on hover and on keyboard focus of a parent with the group class.
  • It holds still with prefers-reduced-motion.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Works in server components

Source

1 file, installed as source you own
roll-text.tsxView on GitHub
import { Fragment, type ComponentProps, type Ref } from "react";
import { cn } from "@/registry/default/lib/utils";

// One splitter for the module: user-perceived characters, so emoji and
// accents stay whole wherever the runtime can tell them apart.
const characterSplitter =
  typeof Intl === "object" && typeof Intl.Segmenter === "function"
    ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
    : null;

function graphemes(text: string) {
  if (!characterSplitter) return [...text];
  const pieces: string[] = [];
  for (const { segment } of characterSplitter.segment(text)) pieces.push(segment);
  return pieces;
}

type RollTextTag = "span" | "div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";

interface RollTextProps extends Omit<ComponentProps<"span">, "children"> {
  /** Plain text. */
  children: string;
  /** The element to render. */
  as?: RollTextTag;
  /** Time between one character starting to roll and the next, in ms. */
  stagger?: number;
  /** How long each character takes to roll, in ms. */
  duration?: number;
}

/**
 * Text whose characters roll up, one after another, to reveal a copy of
 * themselves: a small flourish for links and buttons. It rolls when the
 * text is hovered, or when a parent with Tailwind's `group` class is
 * hovered or has keyboard focus, so it follows the link or button it sits
 * in. It is CSS transitions only and works as a server component. Screen
 * readers get the text once; with `prefers-reduced-motion` it holds still.
 * Characters are clipped to the line box, so keep a line height with room
 * for descenders.
 *
 * @example
 * <a href="/about" className="group">
 *   <RollText>About us</RollText>
 * </a>
 */
function RollText({
  children,
  as: Tag = "span",
  stagger = 20,
  duration = 400,
  className,
  ref,
  ...props
}: RollTextProps) {
  // Words and the whitespace between them, whitespace kept as it is.
  const parts = children.split(/(\s+)/).filter(Boolean);
  let next = 0;

  return (
    <Tag
      ref={ref as Ref<never>}
      data-slot="roll-text"
      className={cn("group inline-block whitespace-pre-wrap", className)}
      {...props}
    >
      <span className="sr-only">{children}</span>
      <span aria-hidden="true" data-slot="roll-text-visual">
        {parts.map((part, partIndex) =>
          /^\s+$/.test(part) ? (
            <Fragment key={partIndex}>{part}</Fragment>
          ) : (
            // A word never breaks between its characters.
            <span key={partIndex} className="inline-block whitespace-nowrap">
              {graphemes(part).map((character) => {
                const index = next++;
                return (
                  <span
                    key={index}
                    data-slot="roll-text-character"
                    className="relative inline-block overflow-clip align-top"
                  >
                    <span
                      className="relative inline-block transition-transform ease-[cubic-bezier(0.65,0,0.35,1)] motion-safe:group-hover:-translate-y-full motion-safe:group-focus-visible:-translate-y-full motion-reduce:transition-none"
                      style={{
                        transitionDuration: `${duration}ms`,
                        transitionDelay: `${index * stagger}ms`,
                      }}
                    >
                      {character}
                      <span className="absolute top-full left-0">{character}</span>
                    </span>
                  </span>
                );
              })}
            </span>
          )
        )}
      </span>
    </Tag>
  );
}

export { RollText, type RollTextProps };