Text Effect

beta

Text that animates in by character, word or line — blur, rise, mask, spring, centre-out, phrase builds and a wave, as 19 presets.

Think different.

Installation

Terminal
pnpm dlx @dowel-ui/cli add text-effect

npm packages installed: class-variance-authority.

Accessibility

The split pieces are aria-hidden and an sr-only copy carries the whole string, so screen readers read the sentence once, not letter by letter; `as` keeps heading semantics. Nothing is announced on its own: pass aria-live to announce the settled text when `children` changes, never per piece. The motion is decoration — under reduced motion every entrance lands at rest in one frame and the wave stops after one pass, back at rest.

Props

TextEffect

PropTypeDefault
amplitude

wave-text only: peak height of the wave, in pixels.

number8
as

The element to render. Headings keep their role and read as one string.

TextEffectElement"span"
delay

Milliseconds before the first piece moves.

number0
direction

reveal-text only: the way the text travels as it appears.

TextEffectDirection"up"
duration

Milliseconds each piece takes to enter (or one wave cycle). Defaults to the preset's.

number
mode

enter brings the text in. exit takes it out — with the source's own exit where it had one (the phrase builders), otherwise the entrance played backwards. An exit ends hidden; the sr-only copy remains. Has no effect on wave-text, which is a loop rather than an entrance.

"enter" | "exit""enter"
onComplete

Called once the last piece settles. Never called for wave-text.

() => void
replayKey

Change it to replay. Changing children, preset or mode also replays.

Key
stagger

Milliseconds between pieces. Defaults to the preset's.

number
trigger

mount plays immediately; in-view waits until the text first scrolls into view, once. Without IntersectionObserver (or on the server) the text renders at rest.

"mount" | "in-view""mount"

Plus every attribute of <span> except children, ref, preset, by.

Quality

7/7 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add text-effect writes into your project, with imports rewritten to your own path alias.

ui/text-effect.tsx
"use client";

// Ported from SmoothUI Blur Out Up, Bottom Up Letters, Depth Parallax Words, Focus Blur Resolve, Kinetic Center Build, Line By Line Slide, Mask Reveal Up, Micro Scale Fade, Per Character Rise, Reveal Text, Scale Down Fade, Short Slide Down, Short Slide Right, Soft Blur In, Spring Scale In, Stagger From Center, Stagger From Edges, Top Down Letters and Wave Text (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import {
  useCallback,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithRef,
  type CSSProperties,
  type Key,
  type ReactNode,
  type Ref,
} from "react";

import { cn } from "@/lib/utils";

/*
 * One mechanism for nineteen SmoothUI text animations (ADR 0014). Every source
 * splits a string into characters, words or lines and moves each piece from a
 * pose to rest with a stagger; they differ only in numbers. So the numbers
 * live in PRESETS, and the component is one code path that reads them.
 *
 * - The motion is CSS. Each piece carries its stagger position as `--i`; the
 *   hoisted stylesheet turns that into a delay, and every delay and duration
 *   runs through --motion-scale. The sources used `motion`'s tweens, which
 *   are cubic-béziers, so nothing is lost.
 * - Every entrance keyframe ends at rest and fills `both`, so under reduced
 *   motion (one near-instant iteration) the text lands readable, never at
 *   opacity 0. The one loop (wave-text) runs once, back to rest.
 * - The split spans are aria-hidden; an sr-only copy carries the full string,
 *   so a screen reader reads a sentence, not a spelling bee.
 * - Horizontal travel is along the inline axis: "from the left" in the
 *   sources is "from the inline start" here, and mirrors in RTL.
 */

import {
  PREFIX,
  PRESETS,
  STYLES,
  textEffectPresetNames,
  type PresetSpec,
  type TextEffectDirection,
  type TextEffectPreset,
  type TextEffectUnit,
} from "./text-effect-presets";

export {
  textEffectPresetNames,
  type TextEffectDirection,
  type TextEffectPreset,
  type TextEffectUnit,
};

/* Variants -------------------------------------------------------------- */

const presetRoots = Object.fromEntries(
  textEffectPresetNames.map((name) => [name, (PRESETS[name] as PresetSpec).root ?? ""]),
) as Record<TextEffectPreset, string>;

/** Root classes: the layout each source gave its wrapper. */
const textEffectVariants = cva("", {
  variants: {
    preset: presetRoots,
    by: { character: "", word: "", line: "block", whole: "" },
  },
  defaultVariants: { preset: "soft-blur-in" },
});

/* Splitting ------------------------------------------------------------- */

interface Plan {
  unit: TextEffectUnit;
  spec: PresetSpec;
  /** Duration of the piece run, in ms, for the "which ends last" sum. */
  duration: number;
  stagger: number;
  /** Whether pieces animate at all in this run (a `glide` exit moves only the phrase). */
  pieces: boolean;
  /** Whether word slots grow (builder entrances). */
  grow: boolean;
  /** Ends last: a piece index, "group", or null for a loop. */
  last: number | "group" | null;
}

function orderOf(index: number, count: number, order: PresetSpec["order"]): number {
  if (order === "center") return Math.abs(index - (count - 1) / 2);
  if (order === "edges") return Math.min(index, count - 1 - index);
  return index;
}

type Vars = CSSProperties & Record<`--${string}`, string | number>;

function ms(value: number): string {
  return `calc(${String(value)}ms * var(--motion-scale, 1))`;
}

function renderPieces(text: string, plan: Plan): ReactNode[] {
  const { unit, spec } = plan;
  const units =
    unit === "character"
      ? Array.from(text)
      : unit === "word"
        ? text.split(" ")
        : unit === "line"
          ? text.split("\n")
          : [text];

  const piece = (content: string, index: number): ReactNode => {
    const style: Vars = { "--i": orderOf(index, units.length, spec.order) };
    const first = index === 0 && spec.first !== undefined && plan.grow;
    if (first) style["--text-effect-duration"] = ms(spec.first ?? 0);
    let node: ReactNode = (
      <span
        key={index}
        data-slot="text-effect-piece"
        data-last={plan.last === index ? "" : undefined}
        style={style}
      >
        {content}
      </span>
    );
    if (spec.mask) {
      node = (
        <span key={index} data-slot="text-effect-mask">
          {node}
        </span>
      );
    }
    if (spec.layout === "row" || spec.layout === "column") {
      node = (
        <span
          key={index}
          data-slot="text-effect-cell"
          data-first={index === 0 ? "" : undefined}
          style={{ "--i": style["--i"] } as Vars}
        >
          {node}
        </span>
      );
    }
    return node;
  };

  if (spec.layout) return units.map(piece);

  if (unit === "word") {
    // Real spaces between words, so lines wrap where the text would.
    return units.flatMap((word, index) =>
      index === 0 ? [piece(word, index)] : [" ", piece(word, index)],
    );
  }

  if (unit === "character") {
    // Letters are grouped per word so a line never breaks inside one.
    const out: ReactNode[] = [];
    let index = 0;
    for (const [w, word] of text.split(" ").entries()) {
      if (w > 0) out.push(piece(" ", index++));
      const letters = Array.from(word).map((letter) => piece(letter, index++));
      if (letters.length > 0) {
        out.push(
          <span key={`w${String(w)}`} data-slot="text-effect-word">
            {letters}
          </span>,
        );
      }
    }
    return out;
  }

  return units.map(piece);
}

/** The piece (or the phrase) that finishes last, so `onComplete` fires once. */
function lastOf(count: number, plan: Omit<Plan, "last">, groupEnd: number | null) {
  if (plan.spec.loop) return null;
  let best = -1;
  let bestEnd = -Infinity;
  if (plan.pieces) {
    for (let index = 0; index < count; index++) {
      const firstDuration = index === 0 && plan.grow ? plan.spec.first : undefined;
      const end =
        orderOf(index, count, plan.spec.order) * plan.stagger +
        (firstDuration ?? plan.duration);
      if (end >= bestEnd) {
        best = index;
        bestEnd = end;
      }
    }
  }
  if (groupEnd !== null && groupEnd > bestEnd) return "group" as const;
  return best >= 0 ? best : null;
}

function countOf(text: string, unit: TextEffectUnit): number {
  if (unit === "character") return Array.from(text).length;
  if (unit === "word") return text.split(" ").length;
  if (unit === "line") return text.split("\n").length;
  return 1;
}

/* Environment ----------------------------------------------------------- */

const noop = () => () => {};

function subscribeReducedMotion(onChange: () => void) {
  if (typeof window.matchMedia !== "function") return () => {};
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}

function prefersReducedMotion(): boolean {
  if (typeof window.matchMedia !== "function") return false;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function assignRef<T>(ref: Ref<T> | undefined, node: T | null) {
  if (typeof ref === "function") ref(node);
  else if (ref) ref.current = node;
}

/* Component ------------------------------------------------------------- */

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

export interface TextEffectProps
  extends
    Omit<ComponentPropsWithRef<"span">, "children" | "ref">,
    Omit<VariantProps<typeof textEffectVariants>, "preset" | "by"> {
  /** The text. A string, because it is split; use `\n` to separate lines. */
  children: string;
  ref?: Ref<HTMLElement>;
  /** The element to render. Headings keep their role and read as one string. */
  as?: TextEffectElement;
  /** Which SmoothUI animation to play. */
  preset?: TextEffectPreset;
  /**
   * Split unit. Defaults to the source's. The phrase builders
   * (kinetic-center-build, short-slide-down, short-slide-right) are always
   * per word: their layout is the effect. Prefer `word` or `line` for
   * joined scripts (Arabic, Devanagari): separate letters cannot join.
   */
  by?: TextEffectUnit;
  /** Milliseconds before the first piece moves. */
  delay?: number;
  /** Milliseconds between pieces. Defaults to the preset's. */
  stagger?: number;
  /** Milliseconds each piece takes to enter (or one wave cycle). Defaults to the preset's. */
  duration?: number;
  /** reveal-text only: the way the text travels as it appears. */
  direction?: TextEffectDirection;
  /** wave-text only: peak height of the wave, in pixels. */
  amplitude?: number;
  /**
   * `mount` plays immediately; `in-view` waits until the text first scrolls
   * into view, once. Without IntersectionObserver (or on the server) the text
   * renders at rest.
   */
  trigger?: "mount" | "in-view";
  /**
   * `enter` brings the text in. `exit` takes it out — with the source's own
   * exit where it had one (the phrase builders), otherwise the entrance
   * played backwards. An exit ends hidden; the sr-only copy remains. Has no
   * effect on wave-text, which is a loop rather than an entrance.
   */
  mode?: "enter" | "exit";
  /** Change it to replay. Changing `children`, `preset` or `mode` also replays. */
  replayKey?: Key;
  /** Called once the last piece settles. Never called for wave-text. */
  onComplete?: () => void;
}

/** Text that animates in, piece by piece, in one of nineteen SmoothUI motions. */
export function TextEffect({
  children,
  ref,
  as: Comp = "span",
  preset = "soft-blur-in",
  by,
  delay = 0,
  stagger,
  duration,
  direction = "up",
  amplitude = 8,
  trigger = "mount",
  mode = "enter",
  replayKey,
  onComplete,
  className,
  style,
  ...props
}: TextEffectProps) {
  const spec: PresetSpec = PRESETS[preset];
  const unit = spec.layout ? spec.by : (by ?? spec.by);
  const exiting = mode === "exit" && !spec.loop;

  const reduced = useSyncExternalStore(
    subscribeReducedMotion,
    prefersReducedMotion,
    () => false,
  );
  // Server snapshot false: an in-view effect renders at rest on the server and
  // wherever IntersectionObserver is missing, rather than waiting forever.
  const observable = useSyncExternalStore(
    noop,
    () => typeof IntersectionObserver !== "undefined",
    () => false,
  );
  const [seen, setSeen] = useState(false);
  const node = useRef<HTMLElement | null>(null);

  const setRef = useCallback(
    (element: HTMLElement | null) => {
      node.current = element;
      assignRef(ref, element);
    },
    [ref],
  );

  const waiting = trigger === "in-view" && !seen;

  useEffect(() => {
    const element = node.current;
    if (!waiting || !observable || !element) return;
    const observer = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) {
        setSeen(true);
        observer.disconnect();
      }
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [waiting, observable]);

  // A native listener rather than onAnimationEnd: React listens for a
  // vendor-prefixed event name wherever AnimationEvent is missing.
  const complete = useRef(onComplete);
  useEffect(() => {
    complete.current = onComplete;
  });
  useEffect(() => {
    const element = node.current;
    if (!element) return;
    const onEnd = (event: Event) => {
      if (event.target instanceof Element && event.target.hasAttribute("data-last")) {
        complete.current?.();
      }
    };
    element.addEventListener("animationend", onEnd);
    return () => element.removeEventListener("animationend", onEnd);
  }, [Comp]);

  const state = waiting ? (observable ? "idle" : "static") : "playing";
  const animated = state !== "static";

  // What runs: the entrance, the source's exit, or the entrance reversed.
  const exit = exiting ? spec.exit : undefined;
  const id = `${PREFIX}-${preset}`;
  const pieceRun = exit
    ? exit.target === "pieces"
      ? { name: `${id}-exit`, duration: exit.duration, ease: exit.ease, stagger: 0 }
      : null
    : {
        name: spec.directions ? `${id}-${direction}` : id,
        duration: duration ?? spec.duration,
        ease: spec.ease,
        stagger: stagger ?? spec.stagger,
      };
  const groupRun = exit
    ? exit.target === "group"
      ? { name: `${id}-exit`, duration: exit.duration, ease: exit.ease }
      : null
    : spec.group
      ? { name: `${id}-group`, duration: spec.group.duration, ease: spec.group.ease }
      : null;

  const base = {
    unit,
    spec,
    duration: pieceRun?.duration ?? 0,
    stagger: pieceRun?.stagger ?? 0,
    pieces: pieceRun !== null,
    grow: !exiting && (spec.layout === "row" || spec.layout === "column"),
  };
  const plan: Plan = {
    ...base,
    last: lastOf(countOf(children, unit), base, groupRun?.duration ?? null),
  };

  const vars: Vars = {
    "--text-effect-name": animated && pieceRun ? pieceRun.name : "none",
    "--text-effect-duration": ms(plan.duration),
    "--text-effect-ease": pieceRun?.ease ?? "linear",
    "--text-effect-delay": `${String(delay)}ms`,
    "--text-effect-stagger": `${String(plan.stagger)}ms`,
    "--text-effect-count": spec.loop && !reduced ? "infinite" : "1",
    "--text-effect-direction": exiting && !exit ? "reverse" : "normal",
    "--text-effect-grow":
      animated && plan.grow
        ? `${PREFIX}-grow-${spec.layout === "row" ? "inline" : "block"}`
        : "none",
  };
  if (spec.loop) vars["--text-effect-amplitude"] = `${String(amplitude)}px`;
  if (animated && groupRun) {
    Object.assign(vars, {
      animationName: groupRun.name,
      animationDuration: ms(groupRun.duration),
      animationTimingFunction: groupRun.ease,
      animationDelay: ms(delay),
      animationFillMode: "both",
    } satisfies CSSProperties);
  }

  const run = [String(replayKey ?? ""), preset, unit, mode, direction, children].join("");

  return (
    <>
      <style href={PREFIX} precedence="dowel">
        {STYLES}
      </style>
      <Comp
        ref={setRef}
        data-slot="text-effect"
        data-preset={preset}
        data-mode={spec.loop ? "loop" : mode}
        data-state={state}
        className={cn(textEffectVariants({ preset, by: unit }), className)}
        style={style}
        {...props}
      >
        <span data-slot="text-effect-label" className="sr-only">
          {children}
        </span>
        <span
          key={run}
          aria-hidden="true"
          data-slot="text-effect-content"
          data-by={unit}
          data-layout={spec.layout}
          data-last={plan.last === "group" ? "" : undefined}
          style={vars}
        >
          {renderPieces(children, plan)}
        </span>
      </Comp>
    </>
  );
}

export { textEffectVariants };
ui/text-effect-presets.ts
// Ported from SmoothUI text animations (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.

/*
 * The data half of TextEffect: one row per SmoothUI source, and the hoisted
 * stylesheet generated from it. Timing, easing, stagger, distance and blur
 * are the sources' own numbers.
 */

export const PREFIX = "dowel-text-effect";

/** How a string is split. `whole` animates it as one piece. */
export type TextEffectUnit = "character" | "word" | "line" | "whole";

/** Where reveal-text travels to. `start`/`end` follow reading direction. */
export type TextEffectDirection = "up" | "down" | "start" | "end";

/** A resting pose's opposite: where a piece starts (or, for an exit, ends). */
interface Pose {
  /** Defaults to 0. */
  opacity?: number;
  /** Pixels along the inline axis; negative is toward the inline start. */
  x?: number;
  /** Pixels; positive is down. */
  y?: number;
  scale?: number;
  /** Blur radius in pixels. */
  blur?: number;
}

interface Timing {
  /** Milliseconds. */
  duration: number;
  ease: string;
}

export interface PresetSpec extends Timing {
  /** The source's split unit. */
  by: TextEffectUnit;
  /** Milliseconds between pieces. */
  stagger: number;
  /** The entrance pose. */
  from?: Pose;
  /** reveal-text: one entrance pose per `direction`. */
  directions?: Record<TextEffectDirection, Pose>;
  /** Raw keyframes, for a motion that is not pose → rest. */
  frames?: string;
  /** Stagger by distance from the center, or from the nearest edge. */
  order?: "center" | "edges";
  /** Repeats until reduced motion or unmount stops it. */
  loop?: boolean;
  /** Each piece rises out of an overflow-hidden mask. */
  mask?: boolean;
  /**
   * The phrase builders. `row` and `column` grow each word's slot from zero,
   * so the words already placed are pushed aside as the phrase re-centres;
   * `glide` moves the whole phrase while its words fade in. The split is
   * intrinsic to these, so `by` does not apply.
   */
  layout?: "row" | "column" | "glide";
  /** Duration of the first piece, when it differs (it has nothing to push). */
  first?: number;
  /** Motion of the phrase as a whole (`glide`). */
  group?: Timing & { from: Pose };
  /** The source's own exit. Without one, `mode="exit"` plays the entrance backwards. */
  exit?: Timing & { target: "pieces" | "group"; to: Pose };
  /** Classes on the root: the layout the source's root had. */
  root?: string;
}

const EASE_OUT = "var(--ease-out-quint)"; // cubic-bezier(0.22, 1, 0.36, 1), the sources' default
const EASE_STAGE = "cubic-bezier(0.18, 1, 0.32, 1)";
const EASE_BUILD = "cubic-bezier(0.2, 0.8, 0.2, 1)";
const EASE_EXIT = "cubic-bezier(0.4, 0, 0.2, 1)";

export const PRESETS = {
  // An entrance despite its name: words arrive clean from a blur, drifting up.
  "blur-out-up": {
    by: "word",
    stagger: 28,
    duration: 560,
    ease: EASE_OUT,
    from: { blur: 6, y: 10 },
  },
  "bottom-up-letters": {
    by: "character",
    stagger: 88,
    duration: 400,
    ease: EASE_STAGE,
    from: { y: 46 },
  },
  "depth-parallax-words": {
    by: "word",
    stagger: 70,
    duration: 700,
    ease: EASE_OUT,
    from: { blur: 3, scale: 0.92, y: 18 },
  },
  "focus-blur-resolve": {
    by: "whole",
    stagger: 0,
    duration: 760,
    ease: EASE_OUT,
    from: { blur: 14, scale: 1.01, y: 14 },
  },
  "kinetic-center-build": {
    by: "word",
    stagger: 430,
    duration: 430,
    first: 340,
    ease: EASE_BUILD,
    from: { blur: 3.5, scale: 0.992, x: 88, y: 6 },
    layout: "row",
    exit: { target: "pieces", to: { blur: 2.5, y: -6 }, duration: 260, ease: EASE_EXIT },
    root: "block",
  },
  "line-by-line-slide": {
    by: "line",
    stagger: 120,
    duration: 900,
    ease: EASE_OUT,
    from: { x: -48 },
    root: "block",
  },
  "mask-reveal-up": {
    by: "line",
    stagger: 90,
    duration: 760,
    ease: EASE_OUT,
    from: { blur: 6, y: 30 },
    mask: true,
    root: "block",
  },
  "micro-scale-fade": {
    by: "whole",
    stagger: 0,
    duration: 600,
    ease: "cubic-bezier(0.32, 0.72, 0, 1)",
    from: { scale: 0.96 },
  },
  "per-character-rise": {
    by: "character",
    stagger: 24,
    duration: 700,
    ease: EASE_BUILD,
    from: { y: 32 },
  },
  // The source leaves x/y on motion's default spring; a 250ms spring is an overshoot curve.
  "reveal-text": {
    by: "whole",
    stagger: 0,
    duration: 250,
    ease: "var(--ease-overshoot)",
    directions: { up: { y: 24 }, down: { y: -24 }, start: { x: 24 }, end: { x: -24 } },
  },
  "scale-down-fade": {
    by: "whole",
    stagger: 0,
    duration: 520,
    ease: EASE_OUT,
    from: { scale: 1.04, y: 8 },
  },
  "short-slide-down": {
    by: "word",
    stagger: 500,
    duration: 500,
    first: 360,
    ease: EASE_BUILD,
    from: { blur: 2.4, scale: 0.992, y: -28 },
    layout: "column",
    exit: { target: "pieces", to: { blur: 1.2, y: 10 }, duration: 320, ease: EASE_EXIT },
    root: "block",
  },
  "short-slide-right": {
    by: "word",
    stagger: 92,
    duration: 210,
    ease: EASE_BUILD,
    from: {},
    layout: "glide",
    group: { from: { opacity: 1, blur: 1.2, x: -24 }, duration: 520, ease: EASE_BUILD },
    exit: { target: "group", to: { blur: 1, x: 12 }, duration: 320, ease: EASE_EXIT },
    root: "relative inline-block overflow-hidden align-bottom",
  },
  "soft-blur-in": {
    by: "character",
    stagger: 25,
    duration: 900,
    ease: EASE_OUT,
    from: { blur: 12, y: 16 },
  },
  "spring-scale-in": {
    by: "word",
    stagger: 95,
    duration: 360,
    ease: "cubic-bezier(0.34, 1.56, 0.64, 1)",
    from: { scale: 0.7 },
  },
  "stagger-from-center": {
    by: "character",
    stagger: 22,
    duration: 620,
    ease: EASE_OUT,
    from: { blur: 3, y: 12 },
    order: "center",
  },
  "stagger-from-edges": {
    by: "character",
    stagger: 22,
    duration: 620,
    ease: EASE_OUT,
    from: { blur: 3, y: 12 },
    order: "edges",
  },
  "top-down-letters": {
    by: "character",
    stagger: 88,
    duration: 400,
    ease: EASE_STAGE,
    from: { y: -46 },
  },
  // A loop, not an entrance: each letter bobs up, down and back to rest.
  "wave-text": {
    by: "character",
    stagger: 50,
    duration: 1200,
    ease: "cubic-bezier(0.37, 0, 0.63, 1)",
    loop: true,
    frames:
      "0%,50%,100%{transform:translateY(0)}" +
      "25%{transform:translateY(calc(var(--text-effect-amplitude, 8px) * -1))}" +
      "75%{transform:translateY(calc(var(--text-effect-amplitude, 8px) * .5))}",
  },
} satisfies Record<string, PresetSpec>;

export type TextEffectPreset = keyof typeof PRESETS;

/** Every preset, in catalogue order. Stories and tests iterate this. */
export const textEffectPresetNames = Object.keys(PRESETS) as TextEffectPreset[];

/* Keyframes ------------------------------------------------------------- */

function pose(p: Pose): string {
  const rules = [`opacity:${String(p.opacity ?? 0)}`];
  const moves: string[] = [];
  if (p.x || p.y) {
    const x = p.x ? `calc(var(--text-effect-inline, 1) * ${String(p.x)}px)` : "0";
    moves.push(`translate(${x},${String(p.y ?? 0)}px)`);
  }
  if (p.scale !== undefined) moves.push(`scale(${String(p.scale)})`);
  if (moves.length > 0) rules.push(`transform:${moves.join(" ")}`);
  if (p.blur) rules.push(`filter:blur(${String(p.blur)}px)`);
  return rules.join(";");
}

/** Rest for exactly the properties a pose moves: visible, untransformed, sharp. */
function rest(p: Pose): string {
  const rules = ["opacity:1"];
  if (p.x || p.y || p.scale !== undefined) rules.push("transform:none");
  if (p.blur) rules.push("filter:none");
  return rules.join(";");
}

const enter = (name: string, p: Pose) => `@keyframes ${name}{from{${pose(p)}}to{${rest(p)}}}`;
const leave = (name: string, p: Pose) => `@keyframes ${name}{from{${rest(p)}}to{${pose(p)}}}`;

function keyframesFor(name: TextEffectPreset): string {
  const spec: PresetSpec = PRESETS[name];
  const id = `${PREFIX}-${name}`;
  const out: string[] = [];
  if (spec.frames) out.push(`@keyframes ${id}{${spec.frames}}`);
  if (spec.from) out.push(enter(id, spec.from));
  if (spec.directions) {
    for (const [direction, p] of Object.entries(spec.directions)) {
      out.push(enter(`${id}-${direction}`, p));
    }
  }
  if (spec.group) out.push(enter(`${id}-group`, spec.group.from));
  if (spec.exit) out.push(leave(`${id}-exit`, spec.exit.to));
  return out.join("\n");
}

const PIECE = "[data-slot=text-effect-piece]";
const CELL = "[data-slot=text-effect-cell]";
const CONTENT = "[data-slot=text-effect-content]";

export const STYLES = `
[data-slot=text-effect]:dir(rtl){--text-effect-inline:-1}
${PIECE}{display:inline-block;white-space:pre;animation-name:var(--text-effect-name,none);animation-duration:var(--text-effect-duration);animation-timing-function:var(--text-effect-ease);animation-delay:calc((var(--text-effect-delay) + var(--i, 0) * var(--text-effect-stagger)) * var(--motion-scale, 1));animation-iteration-count:var(--text-effect-count,1);animation-direction:var(--text-effect-direction,normal);animation-fill-mode:both}
[data-slot=text-effect-word]{display:inline-block;white-space:nowrap}
[data-slot=text-effect-mask]{display:inline-block;overflow:hidden;vertical-align:bottom}
[data-by=line]>${PIECE},[data-by=line]>[data-slot=text-effect-mask],[data-by=line] [data-slot=text-effect-mask]>${PIECE}{display:block}
${CONTENT}[data-layout=row]{display:flex;flex-wrap:wrap;align-items:center;justify-content:center}
${CONTENT}[data-layout=column]{display:flex;flex-direction:column;align-items:center}
${CONTENT}[data-layout=glide]{display:inline-flex;flex-wrap:nowrap;gap:.25em}
${CELL}{display:grid;grid-template-columns:1fr;grid-template-rows:1fr;min-width:0;min-height:0;animation-name:var(--text-effect-grow,none);animation-duration:var(--text-effect-duration);animation-timing-function:var(--text-effect-ease);animation-delay:calc((var(--text-effect-delay) + var(--i, 0) * var(--text-effect-stagger)) * var(--motion-scale, 1));animation-fill-mode:both}
${CELL}[data-first]{animation-name:none}
${CELL}>${PIECE}{min-width:0;min-height:0}
[data-layout=row]>${CELL}+${CELL}>${PIECE}{padding-inline-start:10px}
[data-layout=column]>${CELL}+${CELL}>${PIECE}{padding-block-start:12px}
[data-slot=text-effect][data-state=idle] :is(${PIECE},${CELL},${CONTENT}){animation-play-state:paused}
@media (prefers-reduced-motion: reduce){${PIECE}{animation-iteration-count:1}}
@keyframes ${PREFIX}-grow-inline{from{grid-template-columns:0fr}to{grid-template-columns:1fr}}
@keyframes ${PREFIX}-grow-block{from{grid-template-rows:0fr}to{grid-template-rows:1fr}}
${textEffectPresetNames.map(keyframesFor).join("\n")}
`;