テキスト
TextScramble
CSS
文字がランダムなグリフを経て左から順に確定する「デコード」テキスト。表示時・ホバー・フレーズのループに対応し、日本語もガタつかずに組めます。
anima.js / text
Design faster.
カーソルを重ねると、もう一度デコードされます。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約29 KB)。
# TextScramble をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: TextScramble
- 説明: 文字がランダムなグリフを経て左から順に確定する「デコード」テキスト。表示時・ホバー・フレーズのループに対応し、日本語もガタつかずに組めます。
- 構成ファイル: `components/text-scramble/index.tsx`, `components/text-scramble/TextScramble.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
追加の npm パッケージは不要です(React のみ)。
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/text-scramble/index.tsx`
```tsx
"use client";
import {
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactNode,
} from "react";
import styles from "./TextScramble.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
/** Built-in glyph sets. Any other string is used as a custom set. */
export type TextScrambleGlyphPreset =
| "auto"
| "latin"
| "katakana"
| "symbols";
export type TextScrambleTrigger = "mount" | "hover" | "loop";
export type TextScrambleProps = {
/** Target text for "mount" / "hover". Falls back to `phrases[0]`. */
text?: string;
/** Phrases cycled by `trigger="loop"`. Falls back to `[text]`. */
phrases?: string[];
/**
* "mount" decodes once when scrolled into view, "hover" re-decodes on
* pointer enter / focus (of the closest interactive ancestor if there
* is one), "loop" cycles through `phrases` with a pause in between.
*/
trigger?: TextScrambleTrigger;
/** Time (ms) from the first glyph to the last character resolving. */
duration?: number;
/** Glyph swaps per second while a character is scrambling. */
speed?: number;
/** "loop" only: how long (ms) a resolved phrase stays before the next. */
pause?: number;
/**
* "auto" (default) scrambles full-width characters (Japanese, CJK) with
* katakana and the rest with latin letters, so a glyph never spills far
* out of its character's box. "latin" / "katakana" / "symbols" force a
* set; any other string is used as a custom glyph set.
*/
glyphs?: TextScrambleGlyphPreset | (string & {});
/** Element to render. Use a heading tag when the text is a heading. */
as?: "h1" | "h2" | "h3" | "h4" | "p" | "span" | "div";
/** Color of the unresolved glyphs and of the resolve flash. */
accentColor?: string;
/** Monospace font stack — every character box is the same width. */
monospace?: boolean;
className?: string;
};
const DEFAULT_ACCENT = "#9aa8ff";
const DEFAULT_DURATION = 1400;
const DEFAULT_SPEED = 22;
const DEFAULT_PAUSE = 2400;
/** The exit (scramble-out) of a loop phrase is shorter than the entry. */
const OUT_RATIO = 0.45;
const OUT_MAX = 700;
const GLYPH_SETS: Record<Exclude<TextScrambleGlyphPreset, "auto">, string> = {
latin: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
katakana:
"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンガギグゲゴザジズゼゾダヂヅデドバビブベボ",
symbols: "!<>-_\\/[]{}=+*^?#$%&@~:;|",
};
/** "auto" narrow set: letters plus a few symbols for the hacker texture. */
const AUTO_NARROW = `${GLYPH_SETS.latin}#$%&*+<>/=?`;
/**
* Full-width code points: CJK, kana, hangul, full-width forms. These
* break lines between characters and get the wide glyph set in "auto".
*/
const WIDE =
/[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꥠ-가-힣豈-︰-﹏-⦆¢-₩]|[\u{20000}-\u{3FFFD}]/u;
/** Japanese closing punctuation sticks to the previous character (kinsoku). */
const CLOSING = /[、。,.!?)」』】〉》〕・ー〜…]/;
const SPACE = /\s/;
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReduced(onChange: () => void) {
const media = window.matchMedia(REDUCED_QUERY);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
/** false on the server; follows the OS "reduce motion" setting afterwards. */
function usePrefersReducedMotion() {
return useSyncExternalStore(
subscribeReduced,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
type Token =
| { kind: "space"; value: string }
| { kind: "cell"; value: string }
| { kind: "word"; chars: string[] };
/**
* Splits text into renderable tokens. Latin words become nowrap groups
* (one inline-block per character would otherwise let a word break
* anywhere); full-width characters stay separate so Japanese can wrap
* between any two characters, except before closing punctuation.
*/
function tokenize(text: string): Token[] {
const tokens: Token[] = [];
let word: string[] = [];
const flush = () => {
if (word.length) tokens.push({ kind: "word", chars: word });
word = [];
};
for (const char of Array.from(text)) {
if (SPACE.test(char)) {
flush();
tokens.push({ kind: "space", value: char });
} else if (WIDE.test(char)) {
flush();
const last = tokens[tokens.length - 1];
if (CLOSING.test(char) && last && last.kind !== "space") {
tokens[tokens.length - 1] = {
kind: "word",
chars: last.kind === "cell" ? [last.value, char] : [...last.chars, char],
};
} else {
tokens.push({ kind: "cell", value: char });
}
} else {
word.push(char);
}
}
flush();
return tokens;
}
/** Glyph picker per character, resolved once per phase. */
function glyphPicker(glyphs: string) {
if (glyphs === "auto") {
const narrow = Array.from(AUTO_NARROW);
const wide = Array.from(GLYPH_SETS.katakana);
return (char: string) => {
const set = WIDE.test(char) ? wide : narrow;
return set[(Math.random() * set.length) | 0];
};
}
const source =
glyphs in GLYPH_SETS
? GLYPH_SETS[glyphs as keyof typeof GLYPH_SETS]
: glyphs || AUTO_NARROW;
const set = Array.from(source);
return () => set[(Math.random() * set.length) | 0];
}
/**
* Per-character state lives in a data attribute written straight to the
* DOM — React renders the structure once per phrase, the animation never
* re-renders. "h" hidden (box kept), "s" scrambling, "r" just resolved
* (plays the settle flash), absent = plain resolved text (SSR / static).
*/
type CellState = "h" | "s" | "r";
type Cell = {
el: HTMLElement;
glyph: HTMLElement;
char: string;
state: CellState | null;
};
function collectCells(root: HTMLElement): Cell[] {
return Array.from(root.querySelectorAll<HTMLElement>("[data-ts-cell]")).map(
(el) => ({
el,
glyph: el.lastElementChild as HTMLElement,
char: el.dataset.tsCell ?? "",
// Read back from the DOM: reused nodes keep the previous phrase's
// state, which a switch to "hover" / reduced motion must clear.
state: (el.getAttribute("data-s") as CellState | null) ?? null,
}),
);
}
function setState(cell: Cell, state: CellState | null) {
if (cell.state === state) return;
cell.state = state;
if (state) cell.el.setAttribute("data-s", state);
else cell.el.removeAttribute("data-s");
}
type PhaseOptions = {
cells: Cell[];
/** "in" resolves left to right; "out" scrambles and fades left to right. */
mode: "in" | "out";
/** "in" only: characters also appear left to right instead of all at once. */
fromHidden: boolean;
duration: number;
speed: number;
pick: (char: string) => string;
onDone: () => void;
};
/** Runs one rAF-driven phase. Returns a cancel function. */
function runPhase({
cells,
mode,
fromHidden,
duration,
speed,
pick,
onDone,
}: PhaseOptions): () => void {
const last = Math.max(1, cells.length - 1);
// Small random offsets keep the sweep from looking like a typewriter.
const timing = cells.map((_, i) => {
const t = i / last;
const jitter = (Math.random() - 0.5) * 0.08 * duration;
if (mode === "in") {
return {
a: fromHidden ? duration * 0.3 * t : 0,
b: Math.max(duration * 0.3 * t + 60, duration * (0.35 + 0.65 * t) + jitter),
};
}
const a = Math.max(0, duration * 0.5 * t + jitter * 0.5);
return { a, b: a + duration * 0.5 };
});
const swapEvery = 1000 / Math.max(1, speed);
let start = -1;
let lastSwap = -Infinity;
let frame = 0;
const tick = (now: number) => {
if (start < 0) start = now;
const elapsed = now - start;
const swap = now - lastSwap >= swapEvery;
if (swap) lastSwap = now;
let pending = false;
cells.forEach((cell, i) => {
const { a, b } = timing[i];
let next: CellState;
if (mode === "in") next = elapsed < a ? "h" : elapsed < b ? "s" : "r";
else next = elapsed < a ? "r" : elapsed < b ? "s" : "h";
const entering = cell.state !== next;
if (next === "s" && (entering || swap)) {
cell.glyph.textContent = pick(cell.char);
}
setState(cell, next);
if (next !== (mode === "in" ? "r" : "h")) pending = true;
});
if (pending) frame = requestAnimationFrame(tick);
else onDone();
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}
/**
* "Decode" text effect: characters cycle through random glyphs and
* resolve left to right into the target text. Every character is an
* inline-block that reserves its final glyph's width, so the line never
* jitters while scrambling. The real text stays in a visually hidden
* span; the animated characters are aria-hidden.
*/
export function TextScramble({
text,
phrases,
trigger = "mount",
duration = DEFAULT_DURATION,
speed = DEFAULT_SPEED,
pause = DEFAULT_PAUSE,
glyphs = "auto",
as: Tag = "span",
accentColor,
monospace = false,
className,
}: TextScrambleProps) {
const rootRef = useRef<HTMLElement>(null);
const visualRef = useRef<HTMLSpanElement>(null);
const reduced = usePrefersReducedMotion();
const [index, setIndex] = useState(0);
const list =
trigger === "loop"
? (phrases ?? []).filter(Boolean).length
? (phrases ?? []).filter(Boolean)
: [text ?? ""]
: [text ?? phrases?.[0] ?? ""];
// Reduced motion freezes a loop on its first phrase: swapping text on a
// timer is still motion, and there would be no way to pause it.
const current = list[reduced ? 0 : index % list.length];
const listKey = list.join("\u0000");
// Layout effect: the initial state (hidden characters) is applied before
// the browser paints the freshly rendered phrase, so it never flashes.
useLayoutEffect(() => {
const root = rootRef.current;
const visual = visualRef.current;
if (!root || !visual) return;
const cells = collectCells(visual);
if (reduced || cells.length === 0) {
cells.forEach((cell) => setState(cell, null));
return;
}
const pick = glyphPicker(glyphs);
const outDuration = Math.min(OUT_MAX, duration * OUT_RATIO);
let cancelPhase: (() => void) | null = null;
let timer = 0;
let running = false;
const stop = () => {
cancelPhase?.();
cancelPhase = null;
window.clearTimeout(timer);
running = false;
};
const play = (
mode: "in" | "out",
fromHidden: boolean,
ms: number,
onDone: () => void,
) => {
running = true;
cancelPhase = runPhase({
cells,
mode,
fromHidden,
duration: ms,
speed,
pick,
onDone: () => {
running = false;
cancelPhase = null;
onDone();
},
});
};
if (trigger === "hover") {
cells.forEach((cell) => setState(cell, null));
// Hovering / focusing a card or link should decode its label too.
const target =
root.closest<HTMLElement>('a[href], button, [role="button"], [tabindex]') ??
root;
const onEnter = () => {
if (!running) play("in", false, duration, () => {});
};
target.addEventListener("pointerenter", onEnter);
target.addEventListener("focusin", onEnter);
return () => {
stop();
target.removeEventListener("pointerenter", onEnter);
target.removeEventListener("focusin", onEnter);
};
}
cells.forEach((cell) => setState(cell, "h"));
if (trigger === "mount") {
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
play("in", true, duration, () => {});
},
// Bottom margin: start once the text is properly on screen. No
// threshold ratio — a tall block might never reach it.
{ rootMargin: "0px 0px -12% 0px" },
);
observer.observe(root);
return () => {
observer.disconnect();
stop();
};
}
// Loop: decode → hold → scramble out → next phrase (a new render, which
// re-runs this effect). Offscreen, the cycle stops and restarts from
// the decode of the current phrase when it comes back.
const cycle = () => {
play("in", true, duration, () => {
timer = window.setTimeout(() => {
play("out", false, outDuration, () => setIndex((i) => i + 1));
}, pause);
});
};
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.some((entry) => entry.isIntersecting);
if (visible && !running && !timer) cycle();
else if (!visible) {
stop();
timer = 0;
cells.forEach((cell) => setState(cell, "h"));
}
},
{ threshold: 0.1 },
);
observer.observe(root);
return () => {
observer.disconnect();
stop();
};
// listKey stands in for `list`; `index` re-arms the loop per phrase.
}, [current, index, listKey, trigger, duration, speed, pause, glyphs, reduced]);
// The CSS module carries the default accent; set inline only if changed.
const vars: CSSVars = {};
if (accentColor && accentColor.toLowerCase() !== DEFAULT_ACCENT) {
vars["--ts-accent"] = accentColor;
}
let key = 0;
const cell = (char: string): ReactNode => (
<span key={key++} className={styles.cell} data-ts-cell={char}>
<span className={styles.final}>{char}</span>
<span className={styles.glyph} />
</span>
);
const classes = [styles.root, className].filter(Boolean).join(" ");
return (
<Tag
// A union of intrinsic tags has no single ref type TS can check;
// every option is an HTMLElement, which is all the effect needs.
ref={rootRef as never}
className={classes}
data-text-scramble
data-monospace={monospace || undefined}
style={vars}
>
<span className={styles.srOnly}>{current}</span>
<span ref={visualRef} aria-hidden>
{tokenize(current).map((token) => {
if (token.kind === "space") return token.value;
if (token.kind === "cell") return cell(token.value);
return (
<span key={key++} className={styles.word}>
{token.chars.map(cell)}
</span>
);
})}
</span>
</Tag>
);
}
export default TextScramble;
```
#### `components/text-scramble/TextScramble.module.css`
```css
/* ==========================================================================
Text scramble ("decode") effect
Each character is an inline-block holding two layers:
- .final the real character. It always sits in flow, so the box keeps
the final glyph's width — the line never jitters, whatever
glyph is showing.
- .glyph the random glyph, absolutely centered over the box.
The component writes the per-character state to `data-s` on .cell:
"h" hidden, "s" scrambling, "r" just resolved, none = static text.
========================================================================== */
.root {
/* Default accent; the component only sets it inline when it differs. */
--ts-accent: #9aa8ff;
/* Font, size, weight and color are inherited from the caller. */
}
.root[data-monospace] {
font-family:
ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas,
"Liberation Mono", "Noto Sans Mono CJK JP", monospace;
font-variant-numeric: tabular-nums;
/* Monospace reads too loose at display sizes. */
letter-spacing: -0.02em;
}
/* The accessible copy of the text: read by screen readers, never seen. */
.srOnly {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A latin word: no breaks between its character boxes. */
.word {
white-space: nowrap;
}
.cell {
position: relative;
display: inline-block;
/* Keeps descenders and ascenders from shifting between the layers. */
vertical-align: top;
}
.final {
display: inline-block;
}
.glyph {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
visibility: hidden;
white-space: pre;
pointer-events: none;
color: var(--ts-accent);
opacity: 0.72;
text-shadow: 0 0 0.45em color-mix(in srgb, var(--ts-accent) 55%, transparent);
}
/* Hidden: the box keeps its width, nothing shows. */
.cell[data-s="h"] .final {
visibility: hidden;
}
/* Scrambling: real character hidden, glyph shown in the accent color. */
.cell[data-s="s"] .final {
visibility: hidden;
}
.cell[data-s="s"] .glyph {
visibility: visible;
}
/* Just resolved: the character lands with a short accent flash. Only the
engine sets "r", so static / server-rendered text never flashes. */
.cell[data-s="r"] .final {
animation: ts-settle 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes ts-settle {
from {
color: var(--ts-accent);
text-shadow: 0 0 0.5em color-mix(in srgb, var(--ts-accent) 70%, transparent);
opacity: 0.6;
}
to {
opacity: 1;
}
}
/* The component already skips the animation under reduced motion; this
covers the settle flash if the setting flips mid-animation. */
@media (prefers-reduced-motion: reduce) {
.cell[data-s="r"] .final {
animation: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/text-scramble.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { TextScramble } from "@/components/text-scramble";
// TODO: 文字サイズ・太さ・色は親要素か className で指定してください(コンポーネントはフォントを継承します)
<TextScramble
trigger="loop"
duration={1400}
glyphs="auto"
accentColor="#9aa8ff"
monospace={false}
phrases={["Design faster.","Ship sooner.","デザインを、もっと速く。"]}
as="h2"
/>
```
### 4. 組み込み手順
1. 見出しなら `<TextScramble as="h1" text="…" />` のように `as` で正しいタグを指定する。既定は `<span>` なので、段落の一部や既存の見出しの中にもそのまま置ける。
2. 文字サイズ・太さ・色・字間は**親要素か `className` で指定する**。コンポーネントはフォントを継承し、自分ではサイズを持たない。
3. トリガーを選ぶ: `"mount"`(画面に入ったら 1 回)、`"hover"`(ホバー / フォーカスのたびに再生)、`"loop"`(`phrases` を順に切り替え)。`"hover"` をリンクやボタンの中に置くと、その要素へのホバー・キーボードフォーカスでも再生される。
4. 日本語は `glyphs="auto"`(既定)のままでよい。全角文字はカタカナ、半角文字は英数字で崩れるので、グリフが文字の枠からはみ出さない。
5. `"use client"` はコンポーネント側に付いているので、Server Component の中に直接置いてよい。
6. `npm run build` が通ることを確認し、「OS の視差効果を減らす設定で最終テキストが即表示される」「スクリーンリーダーで崩れた文字ではなく本文が読まれる」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 崩れている間に行の幅がガタガタ揺れる | グリフを本来の文字と入れ替えて描画している | 本来の文字(`.final`)は常にフローに残して `visibility` で隠し、グリフは `position: absolute` で中央に重ねる |
| 英単語の途中で改行される | 1 文字ずつ inline-block にすると、文字の間すべてが改行位置になる | 半角の単語は `white-space: nowrap` のグループで包む(同梱の `tokenize` を外さない) |
| 行頭に「。」「、」が来る | 全角文字を 1 文字ずつ独立させている | 閉じ括弧・句読点は直前の文字と同じ nowrap グループに入れる |
| 毎フレーム再レンダリングで重い | アニメーションの状態を React state に持っている | 状態は `data-s` 属性とグリフの `textContent` に rAF から直接書く。React は構造をフレーズごとに 1 回描くだけ |
| 読み込み直後に完成テキストが一瞬見えてから崩れる | 初期状態を `useEffect` で入れている | `useLayoutEffect` で描画前に「非表示」状態を入れる |
| スクリーンリーダーが記号の羅列を読む | アニメーション用の文字が読み上げ対象になっている | 文字の層は `aria-hidden`、本文は視覚的に隠した `<span>` に入れる(`aria-label` は `<p>` / `<span>` では無視される) |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。外部ライブラリなし(requestAnimationFrame と IntersectionObserver のみ)
- ファイル: `components/text-scramble/TextScramble.tsx`(`"use client"`)+ `TextScramble.module.css` + `index.ts`
- props: `text`、`phrases`(string[])、`trigger`(`"mount"` / `"hover"` / `"loop"`、既定 `"mount"`)、`duration`(ms、既定 1400)、`speed`(グリフの切り替え回数 / 秒、既定 22)、`pause`(ループの静止時間 ms、既定 2400)、`glyphs`(`"auto"` / `"latin"` / `"katakana"` / `"symbols"` / 任意の文字列、既定 `"auto"`)、`as`(`h1`〜`h4` / `p` / `span` / `div`、既定 `span`)、`accentColor`(既定 `#9aa8ff`)、`monospace`、`className`
- アクセント色の既定値は CSS のカスタムプロパティ `--ts-accent` が持ち、既定以外の値のときだけインラインで上書きする
### 見た目
- フォント・サイズ・色は親から継承。`monospace` のときだけ `ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", "Noto Sans Mono CJK JP", monospace`、`tabular-nums`、字間 -0.02em
- 文字ごとに `<span class="cell">`(`display: inline-block; position: relative; vertical-align: top`)。中に 2 層:
- `.final` 本来の文字。常にフローに残るので、枠の幅は最終文字の幅で固定される
- `.glyph` ランダムなグリフ。`position: absolute; top: 0; left: 50%; transform: translateX(-50%)`、色 `--ts-accent`、opacity .72、`text-shadow: 0 0 .45em`(アクセント 55%)
- 状態は `.cell` の `data-s` 属性: `h`(非表示、枠は維持)/ `s`(崩れ中: `.final` を `visibility: hidden`、`.glyph` を表示)/ `r`(確定直後)/ なし(静的なテキスト。SSR の初期状態)
- 確定の瞬間 (`r`): `.final` に 520ms `cubic-bezier(.22,1,.36,1)` のキーフレーム。アクセント色 + 発光 + opacity .6 から通常の色へ戻る。`r` はアニメーションからしか付かないので、SSR の静的テキストは光らない
- 改行: 半角の単語は `white-space: nowrap` のグループ、全角文字は 1 文字ずつ(どこでも改行可)。ただし `、。,.!?)」』】〉》〕・ー〜…` は直前の文字と同じグループに入れて行頭に来させない。空白は崩さずそのまま出力
- グリフ `"auto"`: 全角文字(CJK・かな・全角記号)はカタカナ、それ以外は英数字 + `#$%&*+<>/=?` から選ぶ。サロゲートペアは `Array.from` で 1 文字として扱う
### モーション
- すべて rAF。状態は DOM の `data-s` とグリフの `textContent` に直接書き、React state は毎フレーム更新しない。グリフは `1000 / speed` ms ごとに差し替え
- **入り(in)**: 文字 i の位置 `t = i / (n − 1)`。表示開始 `duration × 0.3 × t`(左から順に現れる。ホバー時は全文字が即崩れ始める)、確定 `duration × (0.35 + 0.65 × t)` ± `duration × 4%` のランダムなずれ(タイプライターっぽさを消す)
- **出(out、ループのみ)**: 長さ `min(700, duration × 0.45)`。文字 i は `out × 0.5 × t` で崩れ始め、さらに `out × 0.5` 後に消える
- **mount**: 描画前(`useLayoutEffect`)に全文字を `h` にし、IntersectionObserver(`rootMargin: 0px 0px -12% 0px`)で画面に入ったら 1 回だけ「入り」
- **hover**: 初期は完成テキスト。`closest('a[href], button, [role="button"], [tabindex]')`(無ければ自身)の `pointerenter` / `focusin` で「入り」。再生中は無視
- **loop**: 入り → `pause` ms 静止 → 出 → 次のフレーズ。画面外に出たら停止して全文字を隠し、戻ったら現在のフレーズの「入り」からやり直す
- `prefers-reduced-motion: reduce`: アニメーションせず最終テキストを即表示。ループは最初のフレーズで止める(タイマーで文字が変わり続けるのも動きで、止める手段がないため)。CSS 側でも確定時のキーフレームを無効化
### アクセシビリティ
- 本文は視覚的に隠した `<span>`(`clip-path: inset(50%)` 方式)に入れ、アニメーション用の文字の層はまるごと `aria-hidden`。`aria-label` は `<p>` / `<span>` では読まれないので使わない
- ループでは隠しテキストも現在のフレーズに更新する(ライブリージョンにはしない — 数秒ごとの読み上げは邪魔になる)
- テキスト自体をフォーカス可能にはしない。ホバー再生は親のリンク / ボタンのフォーカスでも起動する
### 受け入れ条件
- 崩れている間も行の幅・改行位置が一切変わらない(日本語・英語とも)
- 文字が左から順にアクセント色のグリフから確定し、確定の瞬間に淡く光る
- `"loop"` で 3 つのフレーズ(日本語を含む)が「入り → 静止 → 出」で切り替わり、画面外では止まる
- `"hover"` でホバー・キーボードフォーカスのたびに再生される
- 視差効果を減らす設定では完成テキストが即表示され、ループも止まる
- スクリーンリーダーで本文が 1 回だけ、正しく読まれるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/text-scramble.json生成コード
import { TextScramble } from "@/components/text-scramble";
// TODO: 文字サイズ・太さ・色は親要素か className で指定してください(コンポーネントはフォントを継承します)
<TextScramble
trigger="loop"
duration={1400}
glyphs="auto"
accentColor="#9aa8ff"
monospace={false}
phrases={["Design faster.","Ship sooner.","デザインを、もっと速く。"]}
as="h2"
/>