テキスト
TextReveal
Motion
見出しを単語(英語)・文字(日本語)に分けて、ブラー・フェード・スライド・マスクで時間差に浮かび上がらせるテキスト。表示時・ループ・スクロール連動(Apple 風の段落ハイライト)に対応。
anima.js / text reveal
動きで、伝わる。 Motion that speaks.
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約33 KB)。
# TextReveal をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: TextReveal
- 説明: 見出しを単語(英語)・文字(日本語)に分けて、ブラー・フェード・スライド・マスクで時間差に浮かび上がらせるテキスト。表示時・ループ・スクロール連動(Apple 風の段落ハイライト)に対応。
- 実行時に必要な npm パッケージ: framer-motion
- 構成ファイル: `components/text-reveal/index.tsx`, `components/text-reveal/TextReveal.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. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install framer-motion
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/text-reveal/index.tsx`
```tsx
"use client";
import {
MotionConfig,
motion,
useInView,
useScroll,
useTransform,
type MotionValue,
type Variants,
} from "framer-motion";
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
type RefObject,
} from "react";
import styles from "./TextReveal.module.css";
export type TextRevealMode = "enter" | "scroll" | "loop";
export type TextRevealVariant = "blur-up" | "fade" | "slide" | "mask";
export type TextRevealSplit = "auto" | "word" | "char";
export type TextRevealProps = {
/** The text to reveal. `\n` forces a line break. */
text: string;
/** Element to render. Use a heading tag when the text is a heading. */
as?: "h1" | "h2" | "h3" | "p";
/**
* "enter" plays once when scrolled into view, "scroll" ties each piece's
* opacity (20% → 100%) to the element's progress through the viewport
* (or `scrollContainerRef`), "loop" replays every `interval` ms while
* on screen.
*/
mode?: TextRevealMode;
/**
* How each piece arrives ("enter" / "loop"): "blur-up" (blur 8px → 0 +
* rise + fade), "fade", "slide" (a longer rise), "mask" (rises from
* behind a clipped line). "scroll" always uses the opacity highlight.
*/
variant?: TextRevealVariant;
/** Delay (ms) between consecutive pieces. */
stagger?: number;
/** Duration (ms) of one piece's animation. */
duration?: number;
/** "loop" only: time (ms) from one replay start to the next. */
interval?: number;
/**
* "auto" (default) animates latin text word by word and Japanese / CJK
* character by character. "word" keeps Japanese phrases (up to the next
* 、。) together; "char" splits latin words into letters too.
*/
split?: TextRevealSplit;
/** "scroll" only: the scrolling ancestor, when it isn't the window. */
scrollContainerRef?: RefObject<HTMLElement | null>;
/** Paints one gradient across the whole block (override `--tr-gradient`). */
gradient?: boolean;
className?: string;
};
const DEFAULT_STAGGER = 60;
const DEFAULT_DURATION = 900;
const DEFAULT_INTERVAL = 4500;
/** Loop: how long the completed first paint holds before the first replay. */
const FIRST_HOLD = 1400;
/** Loop: the quick dissolve before each replay. */
const OUT_MS = 380;
/** Scroll: opacity of a piece that hasn't been reached yet. */
const SCROLL_DIM = 0.2;
/* ---------------------------------------------------------------------------
Splitting
------------------------------------------------------------------------- */
/**
* Full-width code points: CJK, kana, hangul, full-width forms. These are
* animated per character in "auto" and may wrap between any two of them.
*/
const WIDE =
/[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꥠ-가-힣豈-︰-﹏-⦆¢-₩]|[\u{20000}-\u{3FFFD}]/u;
/** Japanese closing punctuation sticks to the previous piece (kinsoku). */
const CLOSING = /[、。,.!?)」』】〉》〕・ー〜…]/;
/** Opening brackets stick to the next piece. */
const OPENING = /[(「『【〈《〔[{]/;
/** "word" split: a Japanese phrase ends after one of these. */
const PHRASE_END = /[、。,.!?)」』】〉》〕…]/;
const SPACE = /\s/;
type Token =
| { kind: "space" }
| { kind: "break" }
/** A nowrap group; each unit is one animated piece. */
| { kind: "group"; units: string[] };
type GraphemeSegmenter = {
segment: (input: string) => Iterable<{ segment: string }>;
};
/**
* Grapheme clusters, so an emoji sequence or a base + combining mark never
* splits into two animated pieces. `Array.from` (code points) is the
* fallback where Intl.Segmenter is missing.
*/
function graphemes(text: string): string[] {
const Segmenter = (
Intl as unknown as {
Segmenter?: new (
locale?: string,
options?: { granularity: "grapheme" },
) => GraphemeSegmenter;
}
).Segmenter;
if (!Segmenter) return Array.from(text);
return Array.from(
new Segmenter(undefined, { granularity: "grapheme" }).segment(text),
(s) => s.segment,
);
}
/**
* Splits text into nowrap groups of animated units. Latin words are never
* broken (one inline-block per letter would let a word wrap anywhere);
* Japanese can wrap between characters, except before closing punctuation
* or after an opening bracket. Word segmentation of Japanese is done by
* punctuation rather than Intl.Segmenter's dictionary, so the server and
* the browser always produce the same pieces (no hydration mismatch).
*/
function tokenize(text: string, split: TextRevealSplit): Token[] {
const tokens: Token[] = [];
let latin: string[] = [];
let phrase: string[] = [];
let prefix = "";
const lastGroup = () => {
const last = tokens[tokens.length - 1];
return last?.kind === "group" ? last : null;
};
const appendToLast = (char: string) => {
const group = lastGroup();
if (!group) return false;
group.units[group.units.length - 1] += char;
return true;
};
const flushLatin = () => {
if (!latin.length) return;
tokens.push({
kind: "group",
units: split === "char" ? latin : [latin.join("")],
});
latin = [];
};
const flushPhrase = () => {
if (!phrase.length) return;
tokens.push({ kind: "group", units: [phrase.join("")] });
phrase = [];
};
const flushAll = () => {
flushLatin();
flushPhrase();
if (prefix) tokens.push({ kind: "group", units: [prefix] });
prefix = "";
};
for (const char of graphemes(text)) {
if (char === "\n" || char === "\r\n") {
flushAll();
tokens.push({ kind: "break" });
} else if (SPACE.test(char)) {
flushAll();
if (lastGroup()) tokens.push({ kind: "space" });
} else if (WIDE.test(char)) {
flushLatin();
if (split === "word") {
if (OPENING.test(char)) flushPhrase();
// "Hello。": closing punctuation right after a latin word.
if (!phrase.length && CLOSING.test(char) && appendToLast(char)) continue;
phrase.push(char);
if (PHRASE_END.test(char)) flushPhrase();
} else if (CLOSING.test(char) && !prefix && appendToLast(char)) {
// Attached to the previous piece.
} else if (OPENING.test(char)) {
prefix += char;
} else {
tokens.push({ kind: "group", units: [prefix + char] });
prefix = "";
}
} else {
flushPhrase();
latin.push(prefix + char);
prefix = "";
}
}
flushAll();
while (tokens.length && tokens[tokens.length - 1].kind === "space") tokens.pop();
return tokens;
}
/** Renders tokens, numbering the units in reading order for the stagger. */
function renderTokens(
tokens: Token[],
renderUnit: (text: string, index: number) => ReactNode,
): ReactNode[] {
let index = 0;
return tokens.map((token, i) => {
if (token.kind === "space") return " ";
if (token.kind === "break") return <br key={i} />;
return (
<span key={i} className={styles.group}>
{token.units.map((unit) => renderUnit(unit, index++))}
</span>
);
});
}
function countUnits(tokens: Token[]): number {
return tokens.reduce((n, t) => n + (t.kind === "group" ? t.units.length : 0), 0);
}
/* ---------------------------------------------------------------------------
Reduced motion
------------------------------------------------------------------------- */
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 and during hydration (so the markup matches), then
* follows the OS setting. framer's useReducedMotion may differ between the
* server and the first client render.
*/
function usePrefersReducedMotion() {
return useSyncExternalStore(
subscribeReduced,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* ---------------------------------------------------------------------------
Motion
------------------------------------------------------------------------- */
/** Start (hidden) and end (visible) state per variant. Transforms in em so
the motion scales with the font size. */
const FROM: Record<TextRevealVariant, Record<string, string | number>> = {
"blur-up": { opacity: 0, y: "0.35em", filter: "blur(8px)" },
fade: { opacity: 0 },
slide: { opacity: 0, y: "0.9em" },
// Far enough that descenders clear the mask's extended bottom edge.
mask: { y: "135%" },
};
const TO: Record<TextRevealVariant, Record<string, string | number>> = {
"blur-up": { opacity: 1, y: "0em", filter: "blur(0px)" },
fade: { opacity: 1 },
slide: { opacity: 1, y: "0em" },
mask: { y: "0%" },
};
/** Soft deceleration for blur / fade; a sharper expo-out for the rises. */
const EASE: Record<TextRevealVariant, [number, number, number, number]> = {
"blur-up": [0.22, 1, 0.36, 1],
fade: [0.33, 1, 0.68, 1],
slide: [0.16, 1, 0.3, 1],
mask: [0.16, 1, 0.3, 1],
};
function buildVariants(
variant: TextRevealVariant,
stagger: number,
duration: number,
): Variants {
return {
// Also the loop's dissolve: quick, nearly simultaneous, ease-in.
hidden: (i: number) => ({
...FROM[variant],
transition: {
duration: OUT_MS / 1000,
delay: Math.min(i * 0.012, 0.15),
ease: [0.4, 0, 1, 1],
},
}),
visible: (i: number) => ({
...TO[variant],
transition: {
duration: duration / 1000,
delay: (i * stagger) / 1000,
ease: EASE[variant],
},
}),
};
}
type VisualProps = {
tokens: Token[];
rootRef: RefObject<HTMLElement | null>;
variant: TextRevealVariant;
mode: "enter" | "loop";
stagger: number;
duration: number;
interval: number;
};
/** "enter" / "loop": every unit runs the variant, driven by one phase. */
function MotionVisual({
tokens,
rootRef,
variant,
mode,
stagger,
duration,
interval,
}: VisualProps) {
const variants = useMemo(
() => buildVariants(variant, stagger, duration),
[variant, stagger, duration],
);
// "enter": once, when properly on screen. "loop": pauses offscreen.
const inView = useInView(rootRef, {
once: mode === "enter",
margin: mode === "enter" ? "0px 0px -12% 0px" : "0px",
amount: mode === "enter" ? "some" : 0.2,
});
// A loop paints the finished text first (never an empty hero / gallery
// card), holds, then dissolves and replays.
const [phase, setPhase] = useState<"hidden" | "visible">(
mode === "loop" ? "visible" : "hidden",
);
const replayed = useRef(false);
const units = countUnits(tokens);
useEffect(() => {
if (mode !== "loop" || !inView) return;
const reveal = duration + stagger * Math.max(0, units - 1);
const hold = Math.max(600, interval - reveal - OUT_MS);
const delay =
phase === "hidden" ? OUT_MS : replayed.current ? reveal + hold : FIRST_HOLD;
// One state change per phase (not per frame); leaving the viewport
// clears the timer, so offscreen loops cost nothing.
const timer = window.setTimeout(() => {
replayed.current = true;
setPhase((p) => (p === "visible" ? "hidden" : "visible"));
}, delay);
return () => window.clearTimeout(timer);
}, [mode, inView, phase, duration, stagger, interval, units]);
const animate = mode === "enter" ? (inView ? "visible" : "hidden") : phase;
const initial = mode === "loop" ? "visible" : "hidden";
return renderTokens(tokens, (unit, i) => {
const piece = (
<motion.span
key={variant === "mask" ? undefined : i}
className={styles.piece}
data-tr-piece
custom={i}
variants={variants}
initial={initial}
animate={animate}
>
{unit}
</motion.span>
);
return variant === "mask" ? (
<span key={i} className={styles.mask}>
{piece}
</span>
) : (
piece
);
});
}
function ScrollPiece({
text,
progress,
range,
}: {
text: string;
progress: MotionValue<number>;
range: [number, number];
}) {
const opacity = useTransform(progress, range, [SCROLL_DIM, 1]);
return (
<motion.span className={styles.piece} data-tr-piece style={{ opacity }}>
{text}
</motion.span>
);
}
/**
* "scroll": progress runs from the block's top entering the lower edge of
* the viewport (90%) to its bottom passing 45%, so the last word lights up
* while the paragraph is still comfortably on screen. Each piece owns an
* overlapping slice of that progress — a soft wave, not a hard cursor.
*/
function ScrollVisual({
tokens,
rootRef,
containerRef,
}: {
tokens: Token[];
rootRef: RefObject<HTMLElement | null>;
containerRef?: RefObject<HTMLElement | null>;
}) {
const { scrollYProgress } = useScroll({
target: rootRef,
container: containerRef,
offset: ["start 0.9", "end 0.45"],
});
const n = countUnits(tokens);
const span = Math.min(0.5, Math.max(0.08, 3 / Math.max(1, n)));
return renderTokens(tokens, (unit, i) => {
const start = n > 1 ? (i / (n - 1)) * (1 - span) : 0;
return (
<ScrollPiece
key={i}
text={unit}
progress={scrollYProgress}
range={[start, start + span]}
/>
);
});
}
/* ---------------------------------------------------------------------------
Component
------------------------------------------------------------------------- */
/**
* Headline / paragraph reveal: the text is split into words (latin) or
* characters (Japanese) that arrive staggered — blur-up, fade, slide or a
* line mask — on enter, on a loop, or tied to scroll position (the
* Apple-style paragraph highlight). The real text stays in a visually
* hidden span; the animated pieces are aria-hidden. Under reduced motion
* the final text renders statically.
*/
export function TextReveal({
text,
as: Tag = "h2",
mode = "enter",
variant = "blur-up",
stagger = DEFAULT_STAGGER,
duration = DEFAULT_DURATION,
interval = DEFAULT_INTERVAL,
split = "auto",
scrollContainerRef,
gradient = false,
className,
}: TextRevealProps) {
const rootRef = useRef<HTMLElement>(null);
const reduced = usePrefersReducedMotion();
const tokens = useMemo(() => tokenize(text, split), [text, split]);
// Remount the pieces when the structure or the motion model changes:
// `initial` only applies on mount, and a loop must restart cleanly.
const structureKey = `${mode}|${variant}|${split}|${reduced}|${text}`;
// Gradient: background-clip:text on each piece would restart the
// gradient per word. Instead every piece gets the root-sized gradient,
// shifted by its own offset, so together they read as one fill. Offsets
// (offsetLeft/Top) ignore transforms, so animation never needs a
// re-measure — only layout changes do.
useLayoutEffect(() => {
const root = rootRef.current;
if (!gradient || !root) return;
const measure = () => {
root.style.setProperty("--tr-w", `${root.offsetWidth}px`);
root.style.setProperty("--tr-h", `${root.offsetHeight}px`);
root.querySelectorAll<HTMLElement>("[data-tr-piece]").forEach((el) => {
el.style.setProperty("--tr-x", `${-el.offsetLeft}px`);
el.style.setProperty("--tr-y", `${-el.offsetTop}px`);
});
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
let alive = true;
// Web fonts can re-wrap the text without resizing the block.
document.fonts?.ready.then(() => alive && measure());
return () => {
alive = false;
observer.disconnect();
};
}, [gradient, structureKey]);
let visual: ReactNode;
if (reduced) {
visual = renderTokens(tokens, (unit, i) => (
<span key={i} className={styles.piece} data-tr-piece>
{unit}
</span>
));
} else if (mode === "scroll") {
visual = (
<ScrollVisual
tokens={tokens}
rootRef={rootRef}
containerRef={scrollContainerRef}
/>
);
} else {
visual = (
<MotionVisual
tokens={tokens}
rootRef={rootRef}
variant={variant}
mode={mode}
stagger={stagger}
duration={duration}
interval={interval}
/>
);
}
const classes = [styles.root, className].filter(Boolean).join(" ");
return (
<MotionConfig reducedMotion="user">
<Tag
// A union of intrinsic tags has no single ref type TS can check;
// every option is an HTMLElement, which is all the effects need.
ref={rootRef as never}
className={classes}
data-text-reveal
data-gradient={gradient || undefined}
>
<span className={styles.srOnly}>{text}</span>
<span key={structureKey} className={styles.visual} aria-hidden>
{visual}
</span>
</Tag>
</MotionConfig>
);
}
export default TextReveal;
```
#### `components/text-reveal/TextReveal.module.css`
```css
/* ==========================================================================
Text reveal
The text is split into inline-block pieces (words or characters) that
framer-motion animates with transform / opacity / filter. Pieces sit in
nowrap groups so a latin word never breaks mid-word. The component writes
no per-frame styles beyond what framer sets on each piece.
========================================================================== */
.root {
/* Fill used with `gradient`. Override per instance via className/style. */
--tr-gradient: linear-gradient(
100deg,
#ffffff 0%,
#efeaff 28%,
#b9abff 60%,
#7cc8ff 100%
);
/* Offset parent for the gradient measurement (pieces' offsetLeft/Top). */
position: relative;
/* Font, size, weight, line-height and color are inherited from the caller. */
}
/* 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 (or a Japanese character with its punctuation): no breaks. */
.group {
white-space: nowrap;
}
.piece {
display: inline-block;
white-space: pre;
}
/* "mask": the piece rises from behind its own clipped box. clip-path rather
than overflow:hidden — an overflow-hidden inline-block moves its baseline
to the bottom edge and would knock the line out of alignment. The inset is
extended so ascenders, descenders and side bearings of the settled glyph
are never cut. */
.mask {
display: inline-block;
clip-path: inset(-0.25em -0.2em -0.22em -0.2em);
}
/* Gradient: each piece paints the root-sized gradient shifted by its own
offset (set by the component), so the fill runs across the whole block.
The padding / negative margin pair widens the painted background past the
line box so descenders and overhangs aren't clipped, without moving any
text. */
.root[data-gradient] .piece {
padding: 0.14em 0.06em 0.22em;
margin: -0.14em -0.06em -0.22em;
color: transparent;
background-image: var(--tr-gradient);
background-repeat: no-repeat;
background-size: var(--tr-w, 100%) var(--tr-h, 100%);
/* offsetLeft/Top already point at the padding box, which is the
background's positioning area — no extra correction for the padding. */
background-position: var(--tr-x, 0px) var(--tr-y, 0px);
/* Both spellings: Safari still needs the prefix; the pair is safe because
every engine honors at least one of them. */
-webkit-background-clip: text;
background-clip: text;
}
/* Forced colors (Windows high contrast): drop the gradient for real text. */
@media (forced-colors: active) {
.root[data-gradient] .piece {
color: CanvasText;
background: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/text-reveal.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { TextReveal } from "@/components/text-reveal";
// TODO: 文字サイズ・太さ・色は親要素か className で指定してください(コンポーネントはフォントを継承します)
<TextReveal
mode="loop"
variant="blur-up"
stagger={60}
duration={900}
interval={4500}
split="auto"
gradient={true}
text={"動きで、伝わる。\nMotion that speaks."}
as="h2"
/>
```
### 4. 組み込み手順
1. 見出しなら `<TextReveal as="h1" text="…" />` のように `as` で正しいタグを指定する(`h1` / `h2` / `h3` / `p`、既定 `h2`)。改行させたい位置には `\n` を入れる(`text={"動きで、伝わる。\nMotion that speaks."}`)。
2. 文字サイズ・太さ・色・字間・行間は**親要素か `className` で指定する**。コンポーネントはフォントを継承し、自分ではサイズを持たない。
3. モードを選ぶ: `"enter"`(画面に入ったら 1 回、既定)、`"loop"`(`interval` ms ごとに再生。最初は完成状態で描画される)、`"scroll"`(スクロール位置に連動して 20% → 100% で灯る)。
4. `"scroll"` をページ全体ではなく内側のスクロール要素の中で使う場合は、その要素の ref を `scrollContainerRef` に渡す。渡さないと window のスクロールを見るので、内側をスクロールしても進まない。コンテナには必ず `position: relative`(など static 以外)を付ける。
5. `gradient` を付けるとテキスト全体に 1 本のグラデーションが流れる。色は CSS 変数 `--tr-gradient` を `className` / `style` で上書きして変える。
6. `"use client"` はコンポーネント側に付いているので、Server Component の中に直接置いてよい。
7. `npm run build` が通ることを確認し、「OS の視差効果を減らす設定で最終テキストが即表示される」「スクリーンリーダーで本文が 1 回だけ読まれる」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| グラデーションが単語ごとに最初から始まる | 各ピースに `background-clip: text` を個別に掛けている | ルートの幅・高さを `--tr-w` / `--tr-h`、各ピースの `offsetLeft/Top` を `--tr-x` / `--tr-y` に書き、全ピースに同じ大きさのグラデーションをずらして敷く(同梱の計測を外さない) |
| グラデーションで g / y の下が欠ける | 背景は要素の箱の中にしか描かれず、行間が詰まると下端がはみ出る | ピースに `padding` と同じ量の負の `margin` を付けて背景の範囲だけ広げる |
| マスクで行がガタつく・下にずれる | `overflow: hidden` の inline-block はベースラインが箱の下端になる | マスクは `clip-path: inset(…)`(負の値で少し広げる)で切り抜く |
| スクロール連動が進まない / 常に 20% のまま | `scrollContainerRef` の要素が `position: static` だと、framer が位置を測る offsetParent の連鎖から外れ、コンテナを通り越して計算される | スクロールコンテナに `position: relative` を付ける |
| 英単語の途中で改行される | 1 文字ずつ inline-block にすると、文字の間すべてが改行位置になる | 単語は `white-space: nowrap` のグループで包む(同梱の `tokenize` を外さない) |
| 行頭に「。」「、」が来る / 「(」が行末に残る | 句読点・括弧を独立したピースにしている | 閉じ側は直前のピースに、開き括弧は次のピースにくっつける |
| ハイドレーションエラー | 日本語の単語分割を `Intl.Segmenter` の辞書で行うと、Node とブラウザの ICU 差で結果が変わる | 単語分割は句読点基準のまま。`Intl.Segmenter` は書記素(grapheme)分割にだけ使う |
| 視差効果を減らしてもブラーとフェードが動く | `MotionConfig reducedMotion="user"` は transform しか止めない | 同梱の reduced-motion 判定で、動きのない静的な `<span>` に切り替える処理を外さない |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)
- ファイル: `components/text-reveal/TextReveal.tsx`(`"use client"`)+ `TextReveal.module.css` + `index.ts`
- props: `text`(必須、`\n` で改行)、`as`(`h1` / `h2` / `h3` / `p`、既定 `h2`)、`mode`(`"enter"` / `"scroll"` / `"loop"`、既定 `"enter"`)、`variant`(`"blur-up"` / `"fade"` / `"slide"` / `"mask"`、既定 `"blur-up"`)、`stagger`(ms、既定 60)、`duration`(ms、既定 900)、`interval`(ループ周期 ms、既定 4500)、`split`(`"auto"` / `"word"` / `"char"`、既定 `"auto"`)、`scrollContainerRef`、`gradient`(既定 false)、`className`
### 見た目
- フォント・サイズ・色・行間は親から継承。ルートは `position: relative`
- 分割: 書記素(`Intl.Segmenter` の grapheme、無ければ `Array.from`)単位で走査
- `auto`: 半角の連続(英単語)は 1 単語 = 1 ピース、全角(日本語・CJK)は 1 文字 = 1 ピース
- `word`: 英語は単語、日本語は `、。,.!?)」』】〉》〕…` までを 1 フレーズ = 1 ピース
- `char`: 英単語も 1 文字ずつ(ただし単語は nowrap グループで包んで途中改行させない)
- `、。,.!?)」』】〉》〕・ー〜…` は直前のピースに、`(「『【〈《〔[{` は次のピースに連結
- 各ピースは `display: inline-block; white-space: pre`。グループは `white-space: nowrap`
- `mask`: ピースを `display: inline-block; clip-path: inset(-.25em -.2em -.22em -.2em)` のラッパーで包む
- `gradient`: 既定 `linear-gradient(100deg, #fff 0%, #efeaff 28%, #b9abff 60%, #7cc8ff 100%)`(`--tr-gradient`)。各ピースは `color: transparent` + `background-clip: text`、`background-size: var(--tr-w) var(--tr-h)`、`background-position: var(--tr-x) var(--tr-y)`。`--tr-w/h` はルートの `offsetWidth/Height`、`--tr-x/y` は各ピースの `-offsetLeft/-offsetTop`(transform の影響を受けないので、アニメーション中の再計測は不要)。ResizeObserver と `document.fonts.ready` で再計測。ピースに `padding: .14em .06em .22em` と同量の負の margin を付けて下端の欠けを防ぐ
- `forced-colors: active` ではグラデーションを外して `CanvasText`
### モーション
- 初期状態 → 最終状態(transform は em 単位でフォントサイズに比例)
- `blur-up`: `opacity 0, y .35em, blur(8px)` → `1, 0, blur(0)`、ease `(.22,1,.36,1)`
- `fade`: `opacity 0 → 1`、ease `(.33,1,.68,1)`
- `slide`: `opacity 0, y .9em` → `1, 0`、ease `(.16,1,.3,1)`
- `mask`: `y 135% → 0%`(不透明度は常に 1)、ease `(.16,1,.3,1)`
- ピース i の遅延 `i × stagger`、長さ `duration`
- **enter**: `useInView`(`once`、`margin: 0px 0px -12% 0px`)で画面に入ったら 1 回
- **loop**: 最初は完成状態で描画 → 1.4 秒静止 → 消える(380ms、ease-in、ほぼ同時)→ 再生 → 静止 → … 再生開始から次の再生開始まで `interval`。画面外(`amount: .2`)ではタイマーを止める。状態の切り替えはフェーズごとの 1 回だけで、フレームごとの React 更新はしない
- **scroll**: `useScroll({ target, container: scrollContainerRef, offset: ["start 0.9", "end 0.45"] })`。ピース i は進捗の区間 `[s, s + w]`(`w = clamp(3 / n, .08, .5)`、`s = i / (n − 1) × (1 − w)`)で不透明度 `.2 → 1`。区間が重なるので、カーソルではなく柔らかい波として灯る。`variant` / `stagger` / `duration` は使わない
- 構造(モード・バリアント・分割・テキスト)が変わったらピースを再マウントして初期状態からやり直す
- `prefers-reduced-motion: reduce`: アニメーションせず静的な最終テキスト(`useSyncExternalStore` で判定。SSR とハイドレーション中は false)。`<MotionConfig reducedMotion="user">` でも包む
### アクセシビリティ
- 本文は視覚的に隠した `<span>`(`clip-path: inset(50%)` 方式)に入れ、アニメーション用のピースの層はまるごと `aria-hidden`。`aria-label` は `<p>` では読まれないので使わない
- 見出しは `as` で正しい見出しレベルにする。テキスト自体はフォーカス可能にしない
### 受け入れ条件
- 日本語は 1 文字ずつ、英語は 1 単語ずつ、ブラーが晴れながら浮かび上がる。句読点が単独で動いたり行頭に来たりしない
- `gradient` で複数行にまたがっても 1 本のグラデーションとしてつながり、下端が欠けない
- `"scroll"` で、スクロールに合わせて単語が 20% から 100% へ順に灯り、戻すと暗くなる。内側のスクロール要素でも `scrollContainerRef` で動く
- ループは画面外で止まり、最初の描画で完成テキストが見える
- 視差効果を減らす設定で、最終テキストが即表示されるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/text-reveal.json生成コード
import { TextReveal } from "@/components/text-reveal";
// TODO: 文字サイズ・太さ・色は親要素か className で指定してください(コンポーネントはフォントを継承します)
<TextReveal
mode="loop"
variant="blur-up"
stagger={60}
duration={900}
interval={4500}
split="auto"
gradient={true}
text={"動きで、伝わる。\nMotion that speaks."}
as="h2"
/>