anima.js
ギャラリー
テキスト

ShinyText

CSS

光の帯が走るシマー、色が流れるグラデーション、発光するオーロラ、クロムのメタリック。background-clip: text だけで動く、流行りのグラデーションテキスト。

✦ 新しくなった anima.js

光るテキストを、CSS だけで。

  • Shimmerシマー
  • Gradientグラデーション
  • Metallicメタリック
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約24 KB)。
# ShinyText をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: ShinyText
- 説明: 光の帯が走るシマー、色が流れるグラデーション、発光するオーロラ、クロムのメタリック。background-clip: text だけで動く、流行りのグラデーションテキスト。
- 構成ファイル: `components/shiny-text/index.tsx`, `components/shiny-text/ShinyText.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/shiny-text/index.tsx`
```tsx
"use client";

import { useEffect, useRef, type CSSProperties } from "react";
import styles from "./ShinyText.module.css";

/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;

export type ShinyTextVariant = "shimmer" | "gradient" | "aurora" | "metallic";

export type ShinyTextProps = {
  /** The text. Takes precedence over `children`. */
  text?: string;
  /** Plain text content; used when `text` is not given. */
  children?: string;
  /**
   * "shimmer" a bright band sweeps across muted text every cycle,
   * "gradient" a multi-stop gradient slowly flows through the text,
   * "aurora" a soft drifting gradient with a blurred glow behind it,
   * "metallic" a chrome gradient with a moving specular streak.
   */
  variant?: ShinyTextVariant;
  /**
   * 2–4 colors. gradient / aurora: the gradient stops. shimmer: tints
   * the edges of the highlight band (first two). metallic: tints the
   * dark band of the chrome (first one).
   */
  colors?: string[];
  /** shimmer only: the muted color of the text outside the band. */
  baseColor?: string;
  /**
   * Seconds per cycle. Defaults per variant: shimmer 3, gradient 6,
   * aurora 8, metallic 4.
   */
  speed?: number;
  /** Gradient / band direction in degrees. */
  angle?: number;
  /** Element to render. Use a heading tag when the text is a heading. */
  as?: "span" | "h1" | "h2" | "p";
  /**
   * Blurred, colored copy of the text behind it. Defaults to on for
   * "aurora" and off for the other variants.
   */
  glow?: boolean;
  className?: string;
};

const DEFAULT_COLORS = ["#a78bfa", "#f472b6", "#60a5fa"] as const;
const DEFAULT_BASE = "#8e8a9f";
const DEFAULT_ANGLE = 110;
const MIN_SPEED = 0.5;

/** 0 → the defaults, 1 → doubled (a gradient needs two stops), max 4. */
function normalizeColors(colors: string[] | undefined): string[] {
  const list = (colors ?? []).filter(Boolean).slice(0, 4);
  if (list.length === 0) return [...DEFAULT_COLORS];
  if (list.length === 1) return [list[0], list[0]];
  return list;
}

/**
 * Animated gradient text — shimmer, flowing gradient, aurora and metallic
 * in one component. Pure CSS: the gradient is painted as the background
 * of an inline span and clipped to the glyphs with `background-clip:
 * text`, so the real text stays real (selectable, translatable, read by
 * screen readers). Browsers without text clipping get a solid, readable
 * color instead. The optional glow is a blurred, aria-hidden duplicate.
 */
export function ShinyText({
  text,
  children,
  variant = "shimmer",
  colors,
  baseColor,
  speed,
  angle,
  as: Tag = "span",
  glow,
  className,
}: ShinyTextProps) {
  const rootRef = useRef<HTMLElement>(null);
  const content = text ?? children ?? "";
  const showGlow = glow ?? variant === "aurora";

  // Pause offscreen: background-position animations repaint the text box
  // every frame (the glow even re-blurs it), which is wasted work for a
  // headline scrolled out of view. Written straight to the DOM — toggling
  // animation-play-state never needs a React render.
  useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver((entries) => {
      const visible = entries.some((entry) => entry.isIntersecting);
      if (visible) el.removeAttribute("data-paused");
      else el.setAttribute("data-paused", "");
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  // The CSS module carries every default; only differences go inline, so
  // the default render is exactly the stylesheet.
  const vars: CSSVars = {};
  const palette = normalizeColors(colors);
  palette.slice(0, 3).forEach((color, i) => {
    if (color.toLowerCase() !== DEFAULT_COLORS[i]) vars[`--st-c${i + 1}`] = color;
  });
  // The stylesheet's stop list assumes three colors; anything else spells
  // the list out. The first color closes it so the flow loops smoothly.
  if (palette.length !== 3) {
    vars["--st-stops"] = [...palette, palette[0]].join(", ");
  }
  if (baseColor && baseColor.toLowerCase() !== DEFAULT_BASE) {
    vars["--st-base"] = baseColor;
  }
  if (typeof speed === "number" && Number.isFinite(speed)) {
    vars["--st-speed"] = `${Math.max(MIN_SPEED, speed)}s`;
  }
  if (typeof angle === "number" && Number.isFinite(angle) && angle !== DEFAULT_ANGLE) {
    vars["--st-angle"] = `${angle}deg`;
  }

  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-shiny-text
      data-variant={variant}
      data-glow={showGlow || undefined}
      data-inline={Tag === "span" || undefined}
      style={vars}
    >
      {showGlow && (
        // Same text, same width → wraps exactly like the real layer it
        // sits under (both share one grid cell).
        <span className={styles.glowLayer} aria-hidden>
          <span className={styles.text}>{content}</span>
        </span>
      )}
      <span className={styles.layer}>
        <span className={styles.text}>{content}</span>
      </span>
    </Tag>
  );
}

export default ShinyText;
```

#### `components/shiny-text/ShinyText.module.css`
```css
/* ==========================================================================
   Shiny text — animated gradient text family

   The gradient is the background of an inline span (.text), clipped to the
   glyphs with background-clip: text, and animated through
   background-position. The real text stays in the DOM as ordinary text.

   Structure:
   .root            the caller's element (span / h1 / h2 / p)
     .glowLayer     optional blurred duplicate (aria-hidden), behind
     .layer         the real text
       .text        inline span that carries the gradient

   Font, size, weight and line-height are inherited from the caller.
   ========================================================================== */

.root {
  /* Defaults. The component only sets these inline when they differ. */
  --st-c1: #a78bfa; /* violet */
  --st-c2: #f472b6; /* pink */
  --st-c3: #60a5fa; /* blue */
  /* Closes on the first color so the flowing variants loop smoothly. */
  --st-stops: var(--st-c1), var(--st-c2), var(--st-c3), var(--st-c1);
  --st-base: #8e8a9f; /* shimmer: muted text outside the band */
  --st-angle: 110deg;
  --st-speed: 3s;
  /* Solid color for browsers without text clipping: must stay readable. */
  --st-fallback: var(--st-base);
}

.root[data-variant="gradient"] {
  --st-speed: 6s;
  --st-fallback: var(--st-c1);
}

.root[data-variant="aurora"] {
  --st-speed: 8s;
  --st-fallback: var(--st-c1);
}

.root[data-variant="metallic"] {
  --st-speed: 4s;
  --st-fallback: #dfe3ea;
}

/* Glow: the real layer and its blurred copy share one grid cell, so the
   copy wraps exactly like the text and needs no absolute positioning
   (which breaks down for multi-line inline boxes). */
.root[data-glow] {
  display: grid;
  isolation: isolate;
}

/* A span stays inline-level. Note: as an atomic box it no longer breaks
   across lines together with surrounding running text. */
.root[data-glow][data-inline] {
  display: inline-grid;
}

.layer,
.glowLayer {
  grid-area: 1 / 1;
}

.glowLayer {
  pointer-events: none;
  user-select: none;
  /* em-based so the glow scales with the type. */
  filter: blur(0.22em) saturate(1.25);
  opacity: 0.6;
}

.text {
  color: var(--st-fallback);
  /* Inline padding does not move the layout, but it extends the painted
     background so descenders and accents (g, y, Japanese dakuten) are not
     cut off when line-height is tight. */
  padding-block: 0.12em;
  /* Each line gets its own full gradient instead of one strip sliced
     across all lines. Unprefixed only: if the build collapses a prefixed
     pair it keeps the -webkit- one, which Firefox does not understand. */
  box-decoration-break: clone;
}

/* Without text clipping, a background would paint a solid block behind the
   letters. Everything gradient-related therefore lives behind @supports;
   other browsers keep the readable solid color above. */
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .text {
    color: transparent;
    background-repeat: no-repeat;
    animation-duration: var(--st-speed);
    animation-iteration-count: infinite;
    /* Longhands only — the `background` shorthand would reset the clip.
       Both spellings on purpose: every current engine (Firefox included)
       understands the -webkit- one, so text clipping survives even if the
       build drops the unprefixed declaration. */
    -webkit-background-clip: text;
    background-clip: text;
  }

  /* --- shimmer: muted text, a bright band sweeps left → right, rests. ---
     300% wide image, band at its middle: position 100% puts the band just
     off the left edge, 0% just off the right edge. */
  .root[data-variant="shimmer"] .text {
    background-image: linear-gradient(
      var(--st-angle),
      var(--st-base) 0%,
      var(--st-base) 41%,
      color-mix(in srgb, var(--st-c1) 75%, var(--st-base)) 46%,
      #fff 50%,
      color-mix(in srgb, var(--st-c2) 75%, var(--st-base)) 54%,
      var(--st-base) 59%,
      var(--st-base) 100%
    );
    background-size: 300% 100%;
    background-position: 100% 0;
    animation-name: st-sweep;
    animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
  }

  /* --- gradient: the stops flow back and forth through the text. --- */
  .root[data-variant="gradient"] .text {
    background-image: linear-gradient(var(--st-angle), var(--st-stops));
    background-size: 300% 100%;
    background-position: 0% 50%;
    animation-name: st-flow;
    animation-timing-function: ease-in-out;
    animation-direction: alternate;
  }

  /* --- aurora: a large soft gradient drifting on both axes. --- */
  .root[data-variant="aurora"] .text {
    background-image:
      radial-gradient(
        60% 80% at 30% 40%,
        color-mix(in srgb, var(--st-c2) 55%, transparent),
        transparent 70%
      ),
      linear-gradient(var(--st-angle), var(--st-stops));
    background-size: 220% 220%;
    background-position: 0% 50%;
    animation-name: st-drift;
    animation-timing-function: ease-in-out;
  }

  /* --- metallic: static chrome + a moving specular streak on top. --- */
  .root[data-variant="metallic"] .text {
    background-image:
      linear-gradient(
        var(--st-angle),
        transparent 44%,
        rgba(255, 255, 255, 0.95) 50%,
        transparent 56%
      ),
      linear-gradient(
        180deg,
        #fbfcfd 0%,
        #e3e6ec 28%,
        color-mix(in srgb, var(--st-c1) 20%, #6c717d) 52%,
        #aeb4bf 66%,
        #eef0f4 88%,
        #ffffff 100%
      );
    background-size:
      300% 100%,
      100% 100%;
    background-position:
      100% 0,
      0 0;
    animation-name: st-streak;
    animation-timing-function: cubic-bezier(0.45, 0, 0.2, 1);
  }
}

/* A blurred solid-color copy would only smudge the fallback. */
@supports not ((background-clip: text) or (-webkit-background-clip: text)) {
  .glowLayer {
    display: none;
  }
}

@keyframes st-sweep {
  0% {
    background-position: 100% 0;
  }
  /* The rest of the cycle is the pause between sweeps. */
  60%,
  100% {
    background-position: 0% 0;
  }
}

@keyframes st-flow {
  from {
    background-position: 0% 50%;
  }
  to {
    background-position: 100% 50%;
  }
}

@keyframes st-drift {
  0%,
  100% {
    background-position: 0% 50%;
  }
  33% {
    background-position: 100% 20%;
  }
  66% {
    background-position: 60% 100%;
  }
}

@keyframes st-streak {
  0% {
    background-position:
      100% 0,
      0 0;
  }
  55%,
  100% {
    background-position:
      0% 0,
      0 0;
  }
}

/* Offscreen (set by the component's IntersectionObserver). */
.root[data-paused] .text {
  animation-play-state: paused;
}

/* Reduced motion: no animation, each variant frozen in its best frame. */
@media (prefers-reduced-motion: reduce) {
  .text {
    animation: none !important;
  }

  /* Band across the middle of the text instead of parked off its edge. */
  .root[data-variant="shimmer"] .text {
    background-position: 50% 0;
  }

  .root[data-variant="gradient"] .text,
  .root[data-variant="aurora"] .text {
    background-position: 50% 50%;
  }

  .root[data-variant="metallic"] .text {
    background-position:
      42% 0,
      0 0;
  }
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/shiny-text.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { ShinyText } from "@/components/shiny-text";

// TODO: 文字サイズ・太さ・字間は親要素か className で指定してください(コンポーネントはフォントを継承します)
<ShinyText
  text="光るテキストを、CSS だけで。"
  variant="aurora"
  baseColor="#8e8a9f"
  glow={true}
  speed={8}
  angle={110}
  colors={["#a78bfa","#f472b6","#60a5fa"]}
  as="h2"
/>
```

### 4. 組み込み手順
1. `<ShinyText text="…" variant="aurora" />` のように置く。見出しなら `as="h1"` / `"h2"` を指定する(既定は `<span>`)。`text` の代わりに子要素の文字列 `<ShinyText>…</ShinyText>` でもよい(文字列のみ。リンクなどの要素は入れない)。
2. 文字サイズ・太さ・字間・行間は**親要素か `className` で指定する**。コンポーネントはフォントを継承し、色だけを自分で塗る。
3. `variant` を選ぶ: `"shimmer"`(くすんだ文字に光の帯が定期的に走る。バッジやラベル向け)、`"gradient"`(色が流れる)、`"aurora"`(柔らかく漂う + 背後の発光)、`"metallic"`(クロム + 反射の筋)。
4. 色は `colors`(2〜4 色)、シマーのくすんだ部分は `baseColor`。既定値は暗い背景向けなので、明るいページでは `baseColor` を濃くする。
5. `"use client"` はコンポーネント側に付いているので、Server Component の中に直接置いてよい。
6. `npm run build` が通ることを確認し、本番ビルドの CSS で `background-clip: text`(または `-webkit-background-clip: text`)が残っていること、「OS の視差効果を減らす設定で静止したグラデーションになる」「文字を選択・コピーできる」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 文字ではなく四角い背景全体がグラデーションになる | `background` ショートハンドを `background-clip` の後に書くとクリップがリセットされる | `background-image` / `-size` / `-position` のロングハンドだけを使う |
| 古いブラウザで文字の後ろに色の箱が出る / 文字が消える | `color: transparent` とグラデーションを無条件に当てている | どちらも `@supports (background-clip: text) or (-webkit-background-clip: text)` の中だけに書き、外では読める単色(`--st-fallback`)にする |
| ビルド後にクリップが効かない | Lightning CSS がプレフィックス付き / なしの組を 1 つにまとめることがある | `-webkit-background-clip: text` と `background-clip: text` の**両方を書く**。どちらが残っても主要ブラウザ(Firefox 含む)で効く |
| 文字が濁る・黒ずむ | `text-shadow` が透明な文字の下から透けて見える | 発光は `glow`(ぼかした複製レイヤー)を使い、`text-shadow` は付けない |
| g や y の下、濁点が切れる | 背景の塗り範囲は行ボックスまでなので、詰めた行間ではグリフがはみ出す | インラインの `.text` に `padding-block: .12em` を残す(インラインの padding はレイアウトを動かさない) |
| 2 行目以降のグラデーションが間延びする | 既定の `box-decoration-break: slice` は全行を 1 本の帯として塗る | `box-decoration-break: clone`(unprefixed のみ。Firefox は `-webkit-` 版を解釈しない) |
| スクリーンリーダーが同じ文を 2 回読む | 発光用の複製が読み上げ対象になっている | 複製レイヤーは `aria-hidden`。本文は普通のテキストのまま |
| 文中の `<span>` で発光をオンにすると改行されない | 発光時は複製と重ねるため `inline-grid` になり、周囲の文と一緒には折り返さない | 段落の途中では `glow={false}`、または見出し単位で使う |

### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。依存パッケージなし(純 CSS + IntersectionObserver)
- ファイル: `components/shiny-text/index.tsx`(`"use client"`)+ `ShinyText.module.css`
- Props: `text` / `children`(文字列、`text` 優先)、`variant`(`"shimmer" | "gradient" | "aurora" | "metallic"`、既定 `"shimmer"`)、`colors`(2〜4 色、既定 `#a78bfa, #f472b6, #60a5fa`)、`baseColor`(既定 `#8e8a9f`)、`speed`(秒/周期)、`angle`(deg、既定 110)、`as`(`"span" | "h1" | "h2" | "p"`)、`glow`(既定: オーロラのみ true)、`className`
- CSS 変数 `--st-c1〜3` / `--st-stops` / `--st-base` / `--st-speed` / `--st-angle` / `--st-fallback` の既定値はルートのクラスに持ち、既定と違う値だけをインラインで渡す。2 色・4 色のときは `--st-stops` を「色…, 先頭の色」で書き出す(先頭で閉じてループを滑らかにする)

### 見た目
- 構造: ルート(`as`)> [発光 `.glowLayer`(`aria-hidden`)] + `.layer` > インラインの `.text`。グラデーションは `.text` の背景で、`background-clip: text` で文字形に切り抜く
- `.text`: `color: var(--st-fallback)`、`padding-block: .12em`、`box-decoration-break: clone`。`@supports` 内でのみ `color: transparent` + 背景 + `-webkit-background-clip: text; background-clip: text` + `background-repeat: no-repeat`
- **shimmer**: `linear-gradient(angle, base 0–41%, mix(c1 75%, base) 46%, #fff 50%, mix(c2 75%, base) 54%, base 59–100%)`、`background-size: 300% 100%`。フォールバックは `baseColor`
- **gradient**: `linear-gradient(angle, c1, c2, c3, c1)`、`300% 100%`。フォールバックは c1
- **aurora**: `radial-gradient(60% 80% at 30% 40%, c2 55%, transparent 70%)` を `linear-gradient(angle, stops)` の上に重ね、`220% 220%`。フォールバックは c1
- **metallic**: 上に反射の筋 `linear-gradient(angle, transparent 44%, 白 .95 50%, transparent 56%)`(`300% 100%`)、下にクロム `linear-gradient(180deg, #fbfcfd 0%, #e3e6ec 28%, mix(c1 20%, #6c717d) 52%, #aeb4bf 66%, #eef0f4 88%, #fff 100%)`。フォールバックは `#dfe3ea`
- **発光**: ルートを `display: grid`(span は `inline-grid`)+ `isolation: isolate`、複製と本文を同じセル(`grid-area: 1/1`)に重ねる。複製は `filter: blur(.22em) saturate(1.25)`、`opacity: .6`、`pointer-events: none`、`user-select: none`。クリップ非対応環境では非表示

### モーション
- すべて `background-position` のアニメーション、周期は `--st-speed`
- shimmer: `100% 0 → 0% 0`(0〜60%、`cubic-bezier(.45,0,.2,1)`)、残り 40% は休止。既定 3 秒
- gradient: `0% 50% ↔ 100% 50%`、ease-in-out・alternate。既定 6 秒
- aurora: `0% 50% → 100% 20% → 60% 100% → 0% 50%`、ease-in-out。既定 8 秒
- metallic: 反射の筋だけ `100% → 0%`(0〜55%)、クロムは固定。既定 4 秒
- 画面外では IntersectionObserver がルートに `data-paused` を付け、`animation-play-state: paused`(React の再レンダリングなし)
- `prefers-reduced-motion: reduce`: アニメーションなし。shimmer は帯を中央(`50% 0`)、gradient / aurora は `50% 50%`、metallic は筋を `42%` に置いた静止状態

### アクセシビリティ
- 本文は普通のテキストとして DOM に残る(選択・コピー・翻訳・読み上げ可)。発光の複製は `aria-hidden`
- 見出しは `as` で正しいタグにする
- フォールバック色・`baseColor` は暗い背景でコントラスト 4.5:1 以上を保つ値にする

### 受け入れ条件
- 4 つのバリエーションがそれぞれ説明どおりに動き、文字の外に背景が塗られない
- `colors` に 2 色・4 色を渡しても切れ目なくループする
- 複数行でも各行に同じグラデーションがかかり、ディセンダー・濁点が欠けない
- `background-clip: text` 非対応環境では単色で読める
- 視差効果を減らす設定で静止し、画面外ではアニメーションが止まる
- スクリーンリーダーが本文を 1 回だけ読む
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/shiny-text.json
生成コード
import { ShinyText } from "@/components/shiny-text";

// TODO: 文字サイズ・太さ・字間は親要素か className で指定してください(コンポーネントはフォントを継承します)
<ShinyText
  text="光るテキストを、CSS だけで。"
  variant="aurora"
  baseColor="#8e8a9f"
  glow={true}
  speed={8}
  angle={110}
  colors={["#a78bfa","#f472b6","#60a5fa"]}
  as="h2"
/>