FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Text Effects

Text Effects

pnpm dlx shadcn@latest add …/r/text-effects.json

Accessible React text effects for Next.js and Tailwind CSS: a shimmer, a word-by-word reveal and a typewriter, all real text and off with reduced motion.

  • Generating a reply…Generating a reply…Shimmer
  • Ship it this afternoonShip it this afternoon

    Reveal
  • Build dialogsBuild forms, menus, dialogs​Typewriter
import { TextReveal, TextShimmer, Typewriter } from "@/components/ui/text-effects"

export function Hero() {
  return (
    <>
      <TextReveal as="h1">Ship it this afternoon</TextReveal>
      <p>
        Build <Typewriter phrases={["forms", "menus", "dialogs"]} />
      </p>
      <TextShimmer>Generating a reply…</TextShimmer>
    </>
  )
}
overview.tsx
import { TextReveal, TextShimmer, Typewriter } from "@/components/ui/text-effects";

/*
 * The three effects side by side, named underneath. Replay plays the reveal and the typing again; the shimmer
 * keeps sweeping.
 */
export default function TextEffectsOverview() {
  return (
    <ul className="grid w-full max-w-2xl grid-cols-1 items-end justify-items-center gap-x-10 gap-y-8 sm:grid-cols-3">
      <li className="flex flex-col items-center gap-2">
        <TextShimmer className="text-sm font-medium">Generating a reply…</TextShimmer>
        <span className="text-xs text-muted-foreground">Shimmer</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <TextReveal className="text-base font-semibold tracking-tight">Ship it this afternoon</TextReveal>
        <span className="text-xs text-muted-foreground">Reveal</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        {/* The longest phrase, invisible, holds the width, so the line doesn't re-centre as it types */}
        <span className="grid text-base font-semibold tracking-tight">
          <span aria-hidden className="invisible col-start-1 row-start-1">
            Build dialogs
          </span>
          <span className="col-start-1 row-start-1">
            Build <Typewriter phrases={["forms", "menus", "dialogs"]} />
          </span>
        </span>
        <span className="text-xs text-muted-foreground">Typewriter</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/text-effects.tsx
    "use client";
    
    import { blur, duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { useReducedMotion } from "motion/react";
    import {
      type ComponentProps,
      Fragment,
      type HTMLAttributes,
      type Ref,
      type RefObject,
      useCallback,
      useEffect,
      useRef,
      useState,
      useSyncExternalStore,
    } from "react";
    
    /*
     * Text Effects (owner's roadmap, 2026-10-16: "Shimmer, reveal and typewriter text"): three calm ways to bring
     * text in, each in one neutral hue and off with reduced motion. The words stay real text for search engines and
     * screen readers.
     * - TextShimmer: a sheen of `foreground` sweeps across text in `muted-foreground`, the lightness of one hue,
     *   never a colour. A copy of the text in the light colour sits inside a soft-edged band (a static mask); the band
     *   slides across while the copy slides back the same amount, so the copy stays put and only the light moves.
     *   Transforms only, on the compositor, not an animated background. It keeps Skeleton's pace (a 1.5s sweep, a
     *   0.5s rest) and its clock, so a shimmering label sweeps in step with the skeletons beside it.
     * - TextReveal: words (or characters) rise 12px through the blur, 0.05s apart (motion.md's list items), once in
     *   view or on mount. The pieces are aria-hidden; a visually hidden copy carries the text. They're hidden in the
     *   server's HTML too, so a reveal above the fold never flashes in place before it plays.
     * - Typewriter: types each phrase, holds it, deletes it and types the next. The caret is solid while it types and
     *   breathes (an eased fade, not a hard blink) while it waits; typing a phrase once, it fades away at the end.
     *   Screen readers aren't sent the typing: a visually hidden copy says every phrase once, and nothing is a live
     *   region, because announcing a phrase every few seconds, forever, would talk over the page. It rests while
     *   off-screen.
     */
    
    const subscribe = () => () => {};
    /** False on the server and while hydrating, so the server's HTML is what hydrates */
    const useHydrated = () =>
      useSyncExternalStore(
        subscribe,
        () => true,
        () => false,
      );
    
    const cubic = (curve: readonly number[]) => `cubic-bezier(${curve.join(", ")})`;
    
    /** Hands a node to our ref and to the caller's */
    function useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) {
      return useCallback(
        (node: T | null) => {
          own.current = node;
          if (typeof theirs === "function") theirs(node);
          else if (theirs) theirs.current = node;
        },
        [own, theirs],
      );
    }
    
    /* ----------------------------------------------------------------------------------------------------------- */
    
    /** Skeleton's sheen: a 2s period, sweeping for the first 75% of it */
    const SHIMMER_PERIOD = duration.slower * 4 * 1000;
    const SHIMMER_SWEEP = 0.75;
    /** The light is brightest in the band's middle and gone a quarter of the way in from each side */
    const band = {
      maskImage: "linear-gradient(to right, transparent 25%, black, transparent 75%)",
      WebkitMaskImage: "linear-gradient(to right, transparent 25%, black, transparent 75%)",
    } as const;
    
    export type TextShimmerProps = ComponentProps<"span">;
    
    /**
     * Text with a light sweeping across it, for a label that says something is happening ("Generating…"). It takes
     * the text's size; the colours are `muted-foreground` with a `foreground` sheen.
     */
    function TextShimmer({ className, children, ...props }: TextShimmerProps) {
      const reduce = useReducedMotion() ?? false;
      const light = useRef<HTMLSpanElement>(null);
      const copy = useRef<HTMLSpanElement>(null);
    
      useEffect(() => {
        const sweep = light.current;
        const still = copy.current;
        if (!sweep || !still || reduce || typeof sweep.animate !== "function") return;
        // From the start of the line in either writing direction
        const [from, to] = getComputedStyle(sweep).direction === "rtl" ? ["100%", "-100%"] : ["-100%", "100%"];
        const back = (value: string) => (value.startsWith("-") ? value.slice(1) : `-${value}`);
        const timing = { duration: SHIMMER_PERIOD, iterations: Number.POSITIVE_INFINITY };
        const easing = cubic(ease.inOut);
        const animations = [
          sweep.animate(
            [{ translate: from, easing }, { translate: to, offset: SHIMMER_SWEEP }, { translate: to }],
            timing,
          ),
          // The copy goes back as far as the band goes on, so the letters never move
          still.animate(
            [
              { translate: back(from), easing },
              { translate: back(to), offset: SHIMMER_SWEEP },
              { translate: back(to) },
            ],
            timing,
          ),
        ];
        // Counted from the page's start, like Skeleton's sheen, so every shimmer and skeleton sweeps together
        for (const animation of animations) animation.startTime = 0;
        return () => {
          for (const animation of animations) animation.cancel();
        };
      }, [reduce]);
    
      return (
        <span
          data-slot="text-shimmer"
          className={cn("relative inline-block text-muted-foreground", className)}
          {...props}
        >
          {children}
          {/* The light: a copy of the text in the foreground colour, seen through a band that sweeps across */}
          <span aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden select-none">
            <span ref={light} className="absolute inset-0 -translate-x-full rtl:translate-x-full" style={band}>
              <span
                ref={copy}
                className="absolute inset-0 translate-x-full text-foreground rtl:-translate-x-full"
              >
                {children}
              </span>
            </span>
          </span>
        </span>
      );
    }
    
    /* ----------------------------------------------------------------------------------------------------------- */
    
    /** motion.md's list items: a 12px rise through the blur, 0.05s apart */
    const REVEAL_RISE = "translateY(12px)";
    const REVEAL_STAGGER = 0.05;
    
    type RevealTag = "p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "li";
    
    export type TextRevealProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
      /** The text to reveal */
      children: string;
      /** The element to render, for example "h2" for a heading */
      as?: RevealTag;
      /** Reveal word by word, or character by character (for short headings) */
      by?: "word" | "character";
      /** Seconds before the first piece rises */
      delay?: number;
      /** Reveal once it scrolls into view. With false, it reveals on mount */
      startOnView?: boolean;
      ref?: Ref<HTMLElement>;
    };
    
    /**
     * Text that rises into place a word (or a character) at a time, once. Screen readers and search engines read the
     * whole text, as it is.
     */
    function TextReveal({
      children,
      as: Comp = "p",
      by = "word",
      delay = 0,
      startOnView = true,
      className,
      ref,
      ...props
    }: TextRevealProps) {
      const reduce = useReducedMotion() ?? false;
      const root = useRef<HTMLElement | null>(null);
      const setRef = useMergedRef(root, ref);
      const animations = useRef<Animation[]>([]);
      // Until it's revealed, every piece is held at opacity 0, from the server's HTML on
      const [shown, setShown] = useState(false);
      const settings = useRef({ delay, startOnView, reduce });
      settings.current = { delay, startOnView, reduce };
    
      useEffect(() => {
        const node = root.current;
        if (!node || shown) return;
        if (settings.current.reduce || typeof node.animate !== "function") {
          setShown(true);
          return;
        }
        const reveal = () => {
          const easing = cubic(ease.out);
          for (const animation of animations.current) animation.cancel();
          animations.current = Array.from(
            node.querySelectorAll<HTMLElement>("[data-slot=text-reveal-piece]"),
            (piece, index) =>
              piece.animate(
                [
                  { opacity: 0, filter: blur.hidden, transform: REVEAL_RISE },
                  { opacity: 1, filter: blur.shown, transform: "none" },
                ],
                {
                  duration: duration.slow * 1000,
                  delay: Math.round((settings.current.delay + index * REVEAL_STAGGER) * 1000),
                  easing,
                  // Holds each piece hidden while it waits; at the end it lets go, leaving no filter behind
                  fill: "backwards",
                },
              ),
          );
          // The pieces drop their inline opacity in the same frame, and the animations take over
          setShown(true);
        };
        if (!settings.current.startOnView || typeof IntersectionObserver === "undefined") {
          reveal();
          return;
        }
        const observer = new IntersectionObserver((entries) => {
          if (!entries.some((entry) => entry.isIntersecting)) return;
          observer.disconnect();
          reveal();
        });
        observer.observe(node);
        return () => observer.disconnect();
      }, [shown]);
    
      useEffect(
        () => () => {
          for (const animation of animations.current) animation.cancel();
        },
        [],
      );
    
      const hidden = shown ? undefined : { opacity: 0 };
      const words = children.split(/\s+/).filter(Boolean);
    
      return (
        <Comp ref={setRef} data-slot="text-reveal" className={className} {...props}>
          <span className="sr-only select-none">{children}</span>
          <span aria-hidden data-slot="text-reveal-text">
            {words.map((word, index) => (
              // biome-ignore lint/suspicious/noArrayIndexKey: the same word can come twice; its place is its identity
              <Fragment key={index}>
                {index > 0 ? " " : null}
                {by === "word" ? (
                  <span data-slot="text-reveal-piece" className="inline-block" style={hidden}>
                    {word}
                  </span>
                ) : (
                  // A word's characters never break apart across lines
                  <span className="inline-block whitespace-nowrap">
                    {Array.from(word).map((char, place) => (
                      // biome-ignore lint/suspicious/noArrayIndexKey: characters repeat; their place is their identity
                      <span key={place} data-slot="text-reveal-piece" className="inline-block" style={hidden}>
                        {char}
                      </span>
                    ))}
                  </span>
                )}
              </Fragment>
            ))}
          </span>
        </Comp>
      );
    }
    
    /* ----------------------------------------------------------------------------------------------------------- */
    
    /** The caret's breath while it waits: in and out over a second */
    const CARET_BREATH = duration.slower * 2 * 1000;
    
    type Typing = {
      phrase: number;
      length: number;
      /** starting: before the first character · between: after deleting, before the next phrase */
      mode: "starting" | "typing" | "holding" | "deleting" | "between" | "done";
    };
    
    export type TypewriterProps = Omit<ComponentProps<"span">, "children"> & {
      /** One phrase, or several to type in turn */
      phrases: string | readonly string[];
      /** Go back to the first phrase after the last. On by default for several phrases, off for one */
      loop?: boolean;
      /** Seconds per character typed */
      typeSpeed?: number;
      /** Seconds per character deleted */
      deleteSpeed?: number;
      /** Seconds a typed phrase stays before it's deleted */
      hold?: number;
      /** Seconds before the first character, once it's in view */
      delay?: number;
      /** Show the caret */
      cursor?: boolean;
    };
    
    /**
     * Types a phrase, or several in turn, with a soft caret. It starts once it's in view and rests while it's
     * off-screen. Screen readers hear every phrase once, as plain text.
     */
    function Typewriter({
      phrases,
      loop,
      typeSpeed = duration.instant,
      deleteSpeed = duration.instant / 2,
      hold = duration.slower * 4,
      delay = 0,
      cursor = true,
      className,
      ref,
      ...props
    }: TypewriterProps) {
      const list = typeof phrases === "string" ? [phrases] : [...phrases];
      const loops = loop ?? list.length > 1;
      const reduce = useReducedMotion() ?? false;
      // With reduced motion the first phrase is simply there; the server's HTML (still empty) hydrates first
      const still = useHydrated() && reduce;
      const root = useRef<HTMLSpanElement | null>(null);
      const setRef = useMergedRef(root, ref);
      const caret = useRef<HTMLSpanElement>(null);
      const caretAnimation = useRef<Animation | null>(null);
      const [typing, setTyping] = useState<Typing>({ phrase: 0, length: 0, mode: "starting" });
      const [inView, setInView] = useState(false);
      const settings = useRef({ list, loops, typeSpeed, deleteSpeed, hold, delay });
      settings.current = { list, loops, typeSpeed, deleteSpeed, hold, delay };
    
      useEffect(() => {
        const node = root.current;
        if (!node || typeof IntersectionObserver === "undefined") {
          setInView(true);
          return;
        }
        const observer = new IntersectionObserver((entries) => {
          const entry = entries.at(-1);
          if (entry) setInView(entry.isIntersecting);
        });
        observer.observe(node);
        return () => observer.disconnect();
      }, []);
    
      // One step at a time: a character typed or deleted, a hold, a pause between phrases
      useEffect(() => {
        if (still || !inView || typing.mode === "done") return;
        const { list, loops, typeSpeed, deleteSpeed, hold, delay } = settings.current;
        const total = Array.from(list[typing.phrase] ?? "").length;
        const last = typing.phrase >= list.length - 1;
        let wait: number;
        let next: Typing;
        switch (typing.mode) {
          case "starting":
          case "between":
            wait = typing.mode === "starting" ? delay : duration.slow;
            next = { ...typing, mode: total > 0 ? "typing" : "holding" };
            break;
          case "typing": {
            const length = typing.length + 1;
            wait = typeSpeed;
            next = { ...typing, length, mode: length >= total ? "holding" : "typing" };
            break;
          }
          case "holding":
            wait = hold;
            next = last && !loops ? { ...typing, mode: "done" } : { ...typing, mode: "deleting" };
            break;
          default: {
            const length = typing.length - 1;
            wait = deleteSpeed;
            next =
              length > 0
                ? { ...typing, length }
                : { phrase: last ? 0 : typing.phrase + 1, length: 0, mode: "between" };
          }
        }
        const timer = setTimeout(() => setTyping(next), wait * 1000);
        return () => clearTimeout(timer);
      }, [typing, inView, still]);
    
      // The caret: solid while the text moves, breathing while it waits, fading away when it's done
      const caretMode =
        typing.mode === "typing" || typing.mode === "deleting"
          ? "solid"
          : typing.mode === "done"
            ? "gone"
            : "wait";
      useEffect(() => {
        const node = caret.current;
        if (!node || typeof node.animate !== "function") return;
        // Read before cancelling, so the fade at the end starts from wherever the breath was
        const opacity = getComputedStyle(node).opacity || "1";
        caretAnimation.current?.cancel();
        caretAnimation.current = null;
        if (caretMode === "wait") {
          caretAnimation.current = node.animate([{ opacity: 1 }, { opacity: 0 }, { opacity: 1 }], {
            duration: CARET_BREATH,
            iterations: Number.POSITIVE_INFINITY,
            easing: cubic(ease.inOut),
          });
        } else if (caretMode === "gone") {
          // The end state, kept until it unmounts
          caretAnimation.current = node.animate([{ opacity }, { opacity: 0 }], {
            duration: duration.slow * 1000,
            easing: cubic(ease.out),
            fill: "forwards",
          });
        }
      }, [caretMode]);
      useEffect(() => () => caretAnimation.current?.cancel(), []);
    
      const typed = still
        ? (list[0] ?? "")
        : Array.from(list[typing.phrase] ?? "")
            .slice(0, typing.length)
            .join("");
    
      return (
        <span ref={setRef} data-slot="typewriter" className={cn("whitespace-pre-wrap", className)} {...props}>
          <span className="sr-only select-none">{list.join(", ")}</span>
          <span aria-hidden data-slot="typewriter-text">
            {typed}
          </span>
          {cursor && !still ? (
            // A 2px bar one line of the text tall: the zero-width space gives it the font's height and baseline
            <span
              ref={caret}
              aria-hidden
              data-slot="typewriter-caret"
              className="ms-0.5 inline-block w-0.5 rounded-full bg-current leading-none select-none"
            >
              {"​"}
            </span>
          ) : null}
        </span>
      );
    }
    
    export { TextReveal, TextShimmer, Typewriter };
  5. 5

    Update the import paths to match your project.

Examples

Reveal by character

by="character" for a short line. Each word stays whole across a line break.

Calm by defaultCalm by default

by-character.tsx
import { TextReveal } from "@/components/ui/text-effects";

/** Character by character, for a short line. Each word stays whole across a line break. */
export default function TextRevealByCharacter() {
  return (
    <TextReveal by="character" className="text-base font-semibold tracking-tight">
      Calm by default
    </TextReveal>
  );
}

Type once

One phrase types once and stays; the caret breathes for a moment, then fades away.

Your workspace is ready.​
type-once.tsx
import { Typewriter } from "@/components/ui/text-effects";

/** One phrase, typed once: the caret breathes for a moment, then fades away. */
export default function TypewriterOnce() {
  return <Typewriter phrases="Your workspace is ready." className="text-sm" />;
}

API reference

TextShimmer

A span whose text is muted-foreground, with a foreground sheen sweeping across it every 2s, in step with Skeleton's sheen. It stops with reduced motion. Also accepts every native span prop.

PropTypeDefaultDescription
children?ReactNode–The text. Keep it to text: it's drawn twice.

TextReveal

Reveals its text once, a word or a character at a time. A visually hidden copy carries the text for screen readers and search engines. With reduced motion it's simply there. Also accepts every native prop of its element.

PropTypeDefaultDescription
childrenstring–The text to reveal.
as?"p" | "span" | "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "li""p"The element to render, for example h2 for a heading.
by?"word" | "character""word"Reveal word by word, or character by character for a short line.
delay?number0Seconds before the first piece rises.
startOnView?booleantrueReveal once it scrolls into view. With false, it reveals as soon as it mounts.

Typewriter

Types its phrases in turn. It starts once it's in view and rests off-screen. Screen readers hear every phrase once, as plain text, and never the typing; nothing is announced as it changes. With reduced motion the first phrase is simply there. Also accepts every native span prop.

PropTypeDefaultDescription
phrasesstring | string[]–One phrase, or several to type in turn.
loop?boolean–Start over after the last phrase. On by default for several phrases, off for one.
typeSpeed?number0.08Seconds per character typed.
deleteSpeed?number0.04Seconds per character deleted.
hold?number2Seconds a typed phrase stays before it's deleted.
delay?number0Seconds before the first character.
cursor?booleantrueShow the caret. It fades away once a phrase typed only once is done.

Related components

  • 12,480Users
    Number Ticker

    An accessible React number ticker for Next.js and Tailwind CSS: digits roll like an odometer and glide as the number changes length, with Intl formatting.

  • Loading profile
    Skeleton

    A React skeleton loader for Next.js and Tailwind CSS: placeholder blocks while content loads, with a calm sheen that sweeps every skeleton in step.

  • NorthwindATLASorbitVertexQuill
    NorthwindATLASorbitVertexQuill
    Marquee

    An accessible React marquee for Next.js and Tailwind CSS: an endless, seamless row of logos or cards that pauses on hover and stops with reduced motion.