First CH Designs
TYP-02Typography

タイプライター+グリッチ見出し

文字送りの速さ・グリッチ量・ノイズの記号セットを、マークアップ側の data 属性で宣言する見出し。エンジンは props ではなくホスト要素の data-typed-* を毎フレーム読み直すので、実行中に属性を書き換えるだけで即反映され、同じ実装を素のHTML+Vanilla JSへそのまま移植できる(Reactは属性へ値を流すだけ)。属性は外部入力として扱い、読み取り時に必ず clamp する(speed=0の無限ループ・巨大なglitch値を弾く)。打ち替えでレイアウトが跳ねないよう、inline-grid の同じセルへ「最長フレーズのinvisibleサイザー」と「動く行」を重ねて箱の寸法を固定し、ノイズ文字は本文と同じ字幅の全角カナを既定にしてちらつきを消している。prefers-reduced-motion では停止ではなく完成形へ降格し(途中まで打った文字列で固まらせない)、打ち替わる側は aria-hidden、全フレーズを通した素のテキストは sr-only で別に置く。依存ゼロ・2026-08-16。

追加日:
2026-08-16
依存:
なし
tags
#typography #heading #typewriter #glitch #data-attributes #interactive #no-dependency #reduced-motion

プレビュー

Typed Headline / TYP-02

私たちがつくるのは、伝わる導線。更新できるサイト。成果の出る一枚。

文字送りの速さ・グリッチ量・ノイズの記号セットを、マークアップ側の data 属性で宣言する見出しです。実行中に属性を書き換えるだけで即座に反映され、打ち替わるあいだも箱の寸法は動きません。

Speed

Glitch

Charset

Layout

data-typed-speed="58"data-typed-glitch="3"data-typed-charset="アイウエオ…"data-typed-preserve="layout"
"use client";

import { useEffect, useId, useRef, useState } from "react";

/**
 * タイプライター+グリッチ見出し(TYP-02)
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 *
 * 1. 設定はマークアップ側の data-* に持たせる(宣言的API)
 *    React の props ではなく、ホスト要素の `data-typed-speed` / `data-typed-glitch` /
 *    `data-typed-charset` / `data-typed-preserve` を **毎フレーム読み直す**。
 *    - 実行中に属性を書き換えるだけで即反映される(再マウント・再初期化が要らない)
 *    - エンジン側は「どのフレームワークから属性が付いたか」を知らない=同じ実装を
 *      素のHTML + Vanilla JS へそのまま移植できる(React はUIの状態を属性へ流すだけ)
 *    - 値は必ず読み取り時に clamp する。属性は外部から任意の文字列が入る入力面であり、
 *      speed=0 で無限ループ、glitch=1e9 で巨大文字列を作るような値を弾く必要がある。
 *    - 数値系は毎フレーム読めば足りるが、構造に効く data-typed-preserve だけは
 *      rAF が止まる reduced-motion 環境でも追随させるため MutationObserver で拾う。
 *
 * 2. レイアウトを1pxも動かさない(preserve-layout)
 *    文字送りは1文字ごとに要素幅が変わる=下線・隣接要素が毎フレーム跳ねる。
 *    `inline-grid` に「最長フレーズを invisible で置いたサイザー」と「実際に動く行」を
 *    **同じセル**(col-start-1 row-start-1)へ重ね、箱の寸法をサイザー側に固定する。
 *    折り返しが起きる幅でも行数が最長フレーズ基準で確保されるので高さも跳ねない。
 *
 * 3. ノイズ文字は「本文と同じ字幅」の集合から採る
 *    和文(全角)の見出しに ASCII 記号(半角)を混ぜると、preserve-layout で箱を固定しても
 *    確定済みの文字列とノイズの境目が毎フレーム伸縮して読みづらい。
 *    全角カナ集合を既定にすると字幅が揃い、ちらつきが消える。
 *
 * 4. reduced-motion は「止める」ではなく「完成形へ降格」
 *    アニメーションを止めるだけだと、途中まで打った文字列で固まる(=文章が壊れる)。
 *    matchMedia を購読し、reduce のときは rAF を回さず最終テキストを描いて終える。
 *    設定変更中に OS 側の設定が変わっても追随できるよう change を購読しておく。
 *
 * 5. 打ち替わる文字は支援技術に読ませない
 *    1文字ずつ変化する要素は aria-hidden にし、全フレーズを通した素のテキストを
 *    sr-only で別に置く(毎フレームの読み上げ・ライブリージョンの暴走を避ける)。
 */

/** 打ち替わるフレーズ(和文・短い1行で書く。長いと折り返してサイザーが太る) */
const PHRASES = ["伝わる導線。", "更新できるサイト。", "成果の出る一枚。"];

/** preserve-layout のサイザー。実測ではなく「最長フレーズ」をそのまま置くだけでよい */
const LONGEST = PHRASES.reduce((a, b) => (Array.from(b).length >= Array.from(a).length ? b : a));

const CHARSETS = {
  kana: { label: "全角カナ", value: "アイウエオカキクケコサシスセソタチツテト" },
  geometric: { label: "幾何", value: "▚▞▙▟░▒▓◆◇" },
  ascii: { label: "記号", value: "/\\<>_|+*#%" },
} as const;

type CharsetKey = keyof typeof CHARSETS;

const SPEEDS = [
  { label: "遅い", value: 96 },
  { label: "標準", value: 58 },
  { label: "速い", value: 26 },
];

const GLITCHES = [
  { label: "なし", value: 0 },
  { label: "標準", value: 3 },
  { label: "強め", value: 6 },
];

const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);

/** 属性値は外部入力として扱う(NaN・負値・極端な値を既定へ丸める) */
const num = (raw: string | undefined, fallback: number, min: number, max: number) => {
  const parsed = Number(raw);
  return Number.isFinite(parsed) ? clamp(parsed, min, max) : fallback;
};

export default function TypedGlitchHeader() {
  // 一覧ページでは同じ標本が複数描画されるので id は必ず発番する(aria の参照が衝突しない)
  const uid = useId();
  const hostRef = useRef<HTMLSpanElement>(null);
  const settledRef = useRef<HTMLSpanElement>(null);
  const noiseRef = useRef<HTMLSpanElement>(null);
  const sizerRef = useRef<HTMLSpanElement>(null);

  const [speed, setSpeed] = useState(58);
  const [glitch, setGlitch] = useState(3);
  const [charset, setCharset] = useState<CharsetKey>("kana");
  const [preserve, setPreserve] = useState(true);

  // タイピングエンジン。state ではなく DOM の textContent を直接書く
  // (1文字ごとに再レンダリングするとフレーム落ちする=文字送りが詰まる)。
  // 依存配列を空にできるのは、設定を props ではなく data-* から毎フレーム読むため。
  useEffect(() => {
    const host = hostRef.current;
    const settled = settledRef.current;
    const noise = noiseRef.current;
    const sizer = sizerRef.current;
    if (!host || !settled || !noise || !sizer) return;

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let raf = 0;
    let index = 0;
    let shown = 0;
    let phase: "typing" | "holding" | "deleting" = "typing";
    let stepAt = 0;
    let noiseAt = 0;
    let noiseText = "";

    const paint = (head: string, tail: string) => {
      if (settled.textContent !== head) settled.textContent = head;
      if (noise.textContent !== tail) noise.textContent = tail;
    };

    // preserve-layout: サイザーを出し入れするだけ(hidden は Tailwind preflight で display:none)
    const applyPreserve = () => {
      const on = host.dataset.typedPreserve !== "off";
      if (sizer.hidden === on) sizer.hidden = !on;
    };

    const frame = (now: number) => {
      const d = host.dataset;
      const speedMs = num(d.typedSpeed, 58, 12, 400);
      const glitchLen = Math.round(num(d.typedGlitch, 3, 0, 12));
      const pool = Array.from(d.typedCharset ?? "");
      const holdMs = num(d.typedHold, 1500, 200, 8000);
      const chars = Array.from(PHRASES[index]);

      if (!stepAt) stepAt = now;

      if (phase === "typing") {
        if (now - stepAt >= speedMs) {
          stepAt = now;
          shown = Math.min(shown + 1, chars.length);
          if (shown === chars.length) phase = "holding";
        }
      } else if (phase === "holding") {
        if (now - stepAt >= holdMs) {
          stepAt = now;
          phase = "deleting";
        }
      } else if (now - stepAt >= speedMs * 0.45) {
        stepAt = now;
        shown = Math.max(shown - 1, 0);
        if (shown === 0) {
          phase = "typing";
          index = (index + 1) % PHRASES.length;
        }
      }

      // 未確定の末尾 glitchLen 文字をノイズに置き換える(保持中はすべて確定させる)
      const settledLen =
        phase === "holding" || pool.length === 0 ? shown : Math.max(shown - glitchLen, 0);
      const noiseLen = shown - settledLen;
      if (noiseLen !== noiseText.length || now - noiseAt >= 70) {
        noiseAt = now;
        noiseText = Array.from(
          { length: noiseLen },
          () => pool[Math.floor(Math.random() * pool.length)],
        ).join("");
      }

      paint(chars.slice(0, settledLen).join(""), noiseText);
      raf = requestAnimationFrame(frame);
    };

    // reduced-motion は完成形(最初のフレーズ)へ降格させる
    const start = () => {
      cancelAnimationFrame(raf);
      applyPreserve();
      if (mq.matches) {
        paint(PHRASES[index], "");
        return;
      }
      stepAt = 0;
      raf = requestAnimationFrame(frame);
    };

    start();
    mq.addEventListener("change", start);

    // 数値系は毎フレーム読めば済むが、構造に効く preserve は rAF が止まっている
    // reduced-motion 環境でも追随させたいので属性を監視する
    const observer = new MutationObserver(applyPreserve);
    observer.observe(host, { attributes: true, attributeFilter: ["data-typed-preserve"] });

    return () => {
      cancelAnimationFrame(raf);
      mq.removeEventListener("change", start);
      observer.disconnect();
    };
  }, []);

  const chip = (active: boolean) =>
    `rounded-full border px-3 py-1 text-xs font-semibold transition-colors duration-200 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
      active
        ? "border-amber-600 bg-amber-600 text-white"
        : "border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-900"
    }`;

  return (
    <section
      aria-labelledby={`${uid}-title`}
      className="w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      <div className="px-5 pt-8 sm:px-10 sm:pt-10">
        <p className="font-display text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
          Typed Headline <span className="text-gray-300">/</span>{" "}
          <span className="text-gray-400">TYP-02</span>
        </p>

        <h2 id={`${uid}-title`} className="mt-5">
          <span className="block text-sm font-semibold tracking-[0.06em] text-gray-500 sm:text-base">
            私たちがつくるのは、
          </span>
          {/* 素のテキスト(支援技術・no-JS 向け)。打ち替わる側は読ませない */}
          <span className="sr-only">{PHRASES.join("")}</span>
          <span
            ref={hostRef}
            aria-hidden="true"
            data-typed-speed={speed}
            data-typed-glitch={glitch}
            data-typed-charset={CHARSETS[charset].value}
            data-typed-hold={1500}
            data-typed-preserve={preserve ? "layout" : "off"}
            className="mt-2 inline-grid border-b border-amber-600/30 pb-2 text-[clamp(1.15rem,4.6vw,2.1rem)] leading-[1.5] font-bold tracking-tight text-gray-900"
          >
            {/* preserve-layout: 最長フレーズを invisible で重ねて箱の寸法を固定する。
                表示/非表示の判断はエンジン側が data-typed-preserve を見て行う(属性が唯一のAPI) */}
            <span ref={sizerRef} className="invisible col-start-1 row-start-1" aria-hidden="true">
              {LONGEST}
              {/* キャレット分の幅も確保する(実測: これが無いと打ち切った瞬間だけ箱が5px広がる) */}
              <span className="ml-0.5 inline-block w-[0.09em]" />
            </span>
            <span className="col-start-1 row-start-1 whitespace-pre-wrap">
              <span ref={settledRef}>{PHRASES[0]}</span>
              <span ref={noiseRef} className="text-amber-600" />
              <span className="ml-0.5 inline-block h-[0.95em] w-[0.09em] translate-y-[0.1em] animate-[tgh-caret_1.05s_step-end_infinite] bg-amber-600 motion-reduce:animate-none" />
            </span>
          </span>
        </h2>

        <p className="mt-6 max-w-lg text-[13px] leading-[1.95] text-gray-600">
          文字送りの速さ・グリッチ量・ノイズの記号セットを、マークアップ側の data 属性で宣言する見出しです。実行中に属性を書き換えるだけで即座に反映され、打ち替わるあいだも箱の寸法は動きません。
        </p>
      </div>

      <div className="mt-8 flex flex-col gap-5 border-t border-gray-200 px-5 py-6 sm:flex-row sm:flex-wrap sm:gap-x-10 sm:px-10">
        <div role="group" aria-labelledby={`${uid}-speed`}>
          <p
            id={`${uid}-speed`}
            className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase"
          >
            Speed
          </p>
          <div className="mt-2 flex gap-2">
            {SPEEDS.map((s) => (
              <button
                key={s.value}
                type="button"
                aria-pressed={speed === s.value}
                onClick={() => setSpeed(s.value)}
                className={chip(speed === s.value)}
              >
                {s.label}
              </button>
            ))}
          </div>
        </div>

        <div role="group" aria-labelledby={`${uid}-glitch`}>
          <p
            id={`${uid}-glitch`}
            className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase"
          >
            Glitch
          </p>
          <div className="mt-2 flex gap-2">
            {GLITCHES.map((g) => (
              <button
                key={g.value}
                type="button"
                aria-pressed={glitch === g.value}
                onClick={() => setGlitch(g.value)}
                className={chip(glitch === g.value)}
              >
                {g.label}
              </button>
            ))}
          </div>
        </div>

        <div role="group" aria-labelledby={`${uid}-charset`}>
          <p
            id={`${uid}-charset`}
            className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase"
          >
            Charset
          </p>
          <div className="mt-2 flex gap-2">
            {(Object.keys(CHARSETS) as CharsetKey[]).map((key) => (
              <button
                key={key}
                type="button"
                aria-pressed={charset === key}
                onClick={() => setCharset(key)}
                className={chip(charset === key)}
              >
                {CHARSETS[key].label}
              </button>
            ))}
          </div>
        </div>

        <div>
          <p className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">Layout</p>
          <button
            type="button"
            aria-pressed={preserve}
            onClick={() => setPreserve((v) => !v)}
            className={`mt-2 inline-flex items-center gap-2 ${chip(preserve)}`}
          >
            <span
              aria-hidden="true"
              className={`h-1.5 w-1.5 rounded-full ${preserve ? "bg-white" : "bg-gray-300"}`}
            />
            レイアウト保持
          </button>
        </div>
      </div>

      {/* 現在の宣言。属性がそのままAPIであることを見せる(コピーして素のHTMLへ貼れる) */}
      <div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-gray-200 bg-gray-50/70 px-5 py-4 font-mono text-[11px] leading-relaxed text-gray-500 sm:px-10">
        <span className="whitespace-nowrap">
          data-typed-speed=<span className="text-amber-700">&quot;{speed}&quot;</span>
        </span>
        <span className="whitespace-nowrap">
          data-typed-glitch=<span className="text-amber-700">&quot;{glitch}&quot;</span>
        </span>
        <span className="whitespace-nowrap">
          data-typed-charset=
          <span className="text-amber-700">
            &quot;{Array.from(CHARSETS[charset].value).slice(0, 5).join("")}…&quot;
          </span>
        </span>
        <span className="whitespace-nowrap">
          data-typed-preserve=
          <span className="text-amber-700">&quot;{preserve ? "layout" : "off"}&quot;</span>
        </span>
      </div>

      {/* キャレットの keyframes はブロック内で閉じる(クラス接頭辞 tgh-) */}
      <style>{`
        @keyframes tgh-caret {
          0%, 50% { opacity: 1; }
          50.01%, 100% { opacity: 0; }
        }
      `}</style>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/typed-glitch-header.json