BLK-107Blocks

Shake-to-Draw Omikuji in JavaScript — Vertical Japanese Fortune Slip with Image Export

Shake the box down and back up once and a numbered stick pops out, then a vertical-text omikuji (Japanese fortune slip) unrolls. The device draws a 1080×1350 PNG with exactly the same layout as the slip, ready to save. The slip reads right to left — stick number, fortune, then six shop-specific entries such as signature dish, best day to visit and wishes — and the entry names, texts and odds are all props. A shake is 80px down plus 80px back up from the lowest point; letting go mid-shake springs the box back. A Draw button and the Space key work too, and prefers-reduced-motion skips the animation. Defaults are café texts with no bad-luck results. No dependencies.

Preview

筒を振って引く、喫茶 ひよりのおみくじ

筒をつかんで下へ、そのまま上へ。1往復で1枚引けます

紙に載る6項目

  1. 1看板の一品
  2. 2今日の一杯
  3. 3来店によい日
  4. 4よい席
  5. 5願い事
  6. 6待ち人

運勢の出やすさ

  • 大吉12%
  • 中吉22%
  • 小吉26%
  • 吉24%
  • 末吉16%

凶は入れていません。重みと文例は props で店ごとに変えられます。

自分の店の項目に差し替える

項目名・運勢・文例・出やすさを渡すだけで、紙と画像の両方が変わります。

shopName紙と画像の下端・印に入る店名
items項目名6つ(各6字以内)
results運勢と各欄の本文(各20字以内)
weightsresults と同じ順の出やすさ
year / shareUrl下端の年と URL
sticks棒の番号の最大。既定は50
bakery-omikuji.tsx
import OmikujiSlip from "@/components/first-ch/omikuji-shake-card";

export default function BakeryOmikuji() {
  return (
    <OmikujiSlip
      shopName="パン工房 こむぎ"
      items={["看板のパン", "焼き上がり", "来店によい日", "合う飲み物", "願い事", "待ち人"]}
      results={[
        { fortune: "大吉", lines: ["塩バターロール。二つ買ってよし", "朝七時の第一便", "晴れた土曜の朝", "牛乳。冷たいほうが吉", "思うまま叶う", "来る。パンの香りに誘われて"] },
        { fortune: "吉", lines: ["くるみの田舎パン", "昼前の第二便", "雨の平日。待たずに買える", "深煎りのコーヒー", "時間はかかるが叶う", "遅れて来る"] },
      ]}
      weights={[30, 70]}
      year="2027 未年"
      shareUrl="example.com/omikuji"
    />
  );
}
Share on X

Read the full description

Shake the box down and back up once and a numbered stick pops out, then a vertical-text omikuji (Japanese fortune slip) unrolls. The device draws a 1080×1350 PNG with exactly the same layout as the slip, ready to save. The slip reads right to left — stick number, fortune, then six shop-specific entries such as signature dish, best day to visit and wishes — and the entry names, texts and odds are all props. A shake is 80px down plus 80px back up from the lowest point; letting go mid-shake springs the box back. A Draw button and the Space key work too, and prefers-reduced-motion skips the animation. Defaults are café texts with no bad-luck results. No dependencies.

Added:
2026-10-29
Dependencies:
None
tags
#block #motion #omikuji #fortune #vertical-writing #writing-mode #canvas #share-image #pointer-events #drag-gesture #shop #cafe #campaign #keyboard #reduced-motion #hairline #no-dependency
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";

/**
 * 縦書きおみくじ(筒を振って引く・結果の画像を保存)
 *
 * 差別化: 近い既存(product-configurator-3d・section-cut-viewer・isometric-floor-map)は、選んだ部位や部屋の表示を切り替えて眺める道具。
 * 本ブロックは「筒を上下に1往復振る」という身ぶりの判定から、縦書きの紙の組版と同じ組版の画像の書き出しまでを1回の操作で終える。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・読み込み時の出現アニメは置かず、hairline 罫線と余白で組んでいる。
 *
 * 汎用技法メモ:
 * 1. **往復の判定は「最も下まで行った位置」からの戻り幅で測る**。下へ 80px 以上動いたら向きの切り替えを待ち、
 *    最下点から上へ 80px 以上戻ったら1往復とする。最下点は下へ動くたびに更新するので、ゆっくり振っても速く振っても同じ判定になる。
 *    筒は pointermove の値を state に通さず、ref で transform だけを書き換えて指に追従させる(毎回の再描画を避ける)。
 * 2. **画面の紙と画像の組版を1つの配置表から作る**。1080×1350 の座標で列の位置・字の大きさを決め、
 *    画面では % と cqw(コンテナの幅に対する割合)に直して absolute で置き、canvas ではそのまま描く。だから画面と画像がずれない。
 * 3. **canvas の縦書きは1字ずつ置く**。長音・ダッシュ・括弧は 90° 回し、句読点は字の枠の右上へ寄せ、小書きの仮名は少し右上へずらす。
 *    字体はページで使っている書体名を getComputedStyle で取り、document.fonts.load で読み込みを待ってから描く。
 * 4. **紙が開く動きは transform だけ**。overflow-hidden の枠の中で紙を translateY(-100%) から 0 へ 400ms で下ろす。
 *    prefers-reduced-motion: reduce では筒の揺れ・棒・紙の動きを省き、判定した瞬間に紙を出す。
 */

const AMBER = "#d97706";
const AMBER_DEEP = "#b45309";
const INK = "#1c1917";
const MUTED = "#78716c";
const RULE = "#d6d3d1";
const PAPER = "#fffdf8";
const SHU = "#c73e1d";
const SHU_DARK = "#9f2f15";
const SHU_LIGHT = "#de5a34";
const KINARI = "#f3ead6";
const LINE = "#4a2418";

/** 1往復と数える移動量(px) */
const SWING = 80;
const OPEN_MS = 400;

const W = 1080;
const H = 1350;
const TOP = 96;
const BODY_TOP = 300;
const ITEM_X = 624;
const ITEM_PITCH = 92;

export type OmikujiResult = {
  /** 運勢(例: 大吉)。2〜3字 */
  fortune: string;
  /** items と同じ順の本文。各20字以内 */
  lines: string[];
};

export type OmikujiSlipProps = {
  /** 紙と画像の下端に入れる店名 */
  shopName?: string;
  /** 項目名(6つ・各6字以内)。店ごとに差し替える */
  items?: string[];
  /** 運勢と各欄の本文 */
  results?: OmikujiResult[];
  /** results と同じ順の出やすさ(合計は自由) */
  weights?: number[];
  /** 紙と画像の下端に入れる年 */
  year?: string;
  /** 画像に載せる URL(https:// は省いて書く) */
  shareUrl?: string;
  /** 棒の番号の最大(1からこの数までが出る) */
  sticks?: number;
  /** 見出しの要素。ページの主見出しにするときだけ "h1" */
  headingTag?: "h1" | "h2" | "p";
};

const DEFAULT_ITEMS = ["看板の一品", "今日の一杯", "来店によい日", "よい席", "願い事", "待ち人"];

const DEFAULT_RESULTS: OmikujiResult[] = [
  {
    fortune: "大吉",
    lines: [
      "焼きたてのスコーン。二つ頼んでよし",
      "深煎りのブレンドが冴える日",
      "雨上がりの午後。席に余裕あり",
      "窓際の二人席。光がよく入る",
      "「言ってみる」が吉。思うまま叶う",
      "来る。連れ立って現れる",
    ],
  },
  {
    fortune: "中吉",
    lines: [
      "季節のタルト。迷ったらこれ",
      "カフェラテ。甘さは控えめに",
      "週のなかば。開店直後の朝",
      "カウンターの端。話が弾む",
      "半分は叶う。残りは次の楽しみに",
      "遅れて来る。先に一杯どうぞ",
    ],
  },
  {
    fortune: "小吉",
    lines: [
      "厚切りトースト。バターは多めに",
      "水出しのアイスコーヒー",
      "平日の夕方。ゆっくりできる",
      "奥のソファ席。本を一冊持って",
      "小さく叶う。続ければ育つ",
      "便りあり。返事は早めに",
    ],
  },
  {
    fortune: "吉",
    lines: [
      "ナポリタン。粉チーズをたっぷり",
      "ほうじ茶ラテ。温かいほうが吉",
      "週末の昼過ぎ。混雑は一段落",
      "入口近くの丸テーブル",
      "時間はかかるが、きっと叶う",
      "来る。ただし話は手短に",
    ],
  },
  {
    fortune: "末吉",
    lines: [
      "固めのプリン。昔ながらの味",
      "ミルクティー。砂糖を一つ",
      "月末の朝。新しい豆が届く",
      "二階の窓際。外を眺めて一息",
      "今は種まき。来月から動く",
      "遅い。待つ間に甘いものを",
    ],
  },
];

const DEFAULT_WEIGHTS = [12, 22, 26, 24, 16];

/** 引く前に見せる紙の見本 */
const SAMPLE: SlipData = {
  number: "第十七番",
  fortune: "大吉",
  items: DEFAULT_ITEMS,
  lines: DEFAULT_RESULTS[0].lines,
  shopName: "喫茶 ひより",
  year: "2027 未年",
  url: "designs.first-ch.com/d/omikuji-shake-card",
};
const DEFAULT_URL = "designs.first-ch.com/d/omikuji-shake-card";

const MOTION_LINKS = [
  { href: "/d/halloween-flashlight-reveal", title: "懐中電灯で隠し文字を探す", note: "なぞって照らし、5文字そろえる" },
  { href: "/d/physics-keyword-pit", title: "物理演算で落ちるタグ", note: "落ちて積もる札を指ではじく" },
  { href: "/d/page-flip-brochure", title: "ページをめくるパンフレット", note: "角をつまんで紙をめくる" },
  { href: "/d/bottom-sheet-drawer", title: "つまみで引き出すボトムシート", note: "上下のドラッグで3段に止まる" },
];

const KANJI_DIGITS = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
/** 1〜99 を漢数字に(17 → 十七) */
export function kanjiNumber(n: number) {
  if (n < 10) return KANJI_DIGITS[n];
  const t = Math.floor(n / 10);
  return `${t === 1 ? "" : KANJI_DIGITS[t]}十${KANJI_DIGITS[n % 10]}`;
}

/** 重みに従って1つ選ぶ */
function pickWeighted(weights: number[], n: number) {
  const ws = Array.from({ length: n }, (_, i) => Math.max(0, weights[i] ?? 0));
  const total = ws.reduce((a, b) => a + b, 0);
  if (total <= 0) return Math.floor(Math.random() * n);
  let r = Math.random() * total;
  for (let i = 0; i < n; i++) {
    r -= ws[i];
    if (r < 0) return i;
  }
  return n - 1;
}

type Draw = { no: number; index: number };
type Col = { key: string; text: string; cx: number; top: number; size: number; color: string; weight: number };
type SlipData = { number: string; fortune: string; items: string[]; lines: string[]; shopName: string; year: string; url: string };

/** 紙の配置表(1080×1350)。画面と画像の両方がこれを使う。列は右から 番号・運勢・項目6欄 */
function slipLayout(d: SlipData) {
  const cols: Col[] = [
    { key: "no", text: d.number, cx: 944, top: TOP, size: 44, color: AMBER_DEEP, weight: 700 },
    { key: "fortune", text: d.fortune, cx: 790, top: TOP + 24, size: 144, color: INK, weight: 700 },
  ];
  d.items.slice(0, 6).forEach((item, i) => {
    const cx = ITEM_X - i * ITEM_PITCH;
    cols.push({ key: `h${i}`, text: item, cx, top: TOP, size: 30, color: AMBER_DEEP, weight: 700 });
    cols.push({ key: `b${i}`, text: d.lines[i] ?? "", cx, top: BODY_TOP, size: 40, color: INK, weight: 500 });
  });
  // 運勢の列の下に、店名を縦に入れた印
  const seal = d.shopName.replace(/\s+/g, "").slice(0, 8);
  const sealH = seal.length * 28 + 28;
  const sealBox = { x: 790 - 30, y: 1096 - sealH, w: 60, h: sealH };
  cols.push({ key: "seal", text: seal, cx: 790, top: sealBox.y + 14, size: 28, color: SHU, weight: 700 });
  const rules = [893, 680, ...[0, 1, 2, 3, 4].map((i) => ITEM_X - i * ITEM_PITCH - ITEM_PITCH / 2)];
  return { cols, rules, sealBox, ruleTop: 72, ruleBottom: 1110 };
}

const ROTATE = /[ー-―—〜~…‥「」『』()()[]【】〔〕〈〉《》<>==\-]/;
const PUNCT = /[、。,.]/;
const SMALL = /[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ]/;

/** canvas に縦書きで1列描く(1字ずつ・長音と括弧は回す) */
function drawVertical(ctx: CanvasRenderingContext2D, c: Col, family: string) {
  ctx.fillStyle = c.color;
  ctx.font = `${c.weight} ${c.size}px ${family}`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  Array.from(c.text).forEach((ch, i) => {
    ctx.save();
    ctx.translate(c.cx, c.top + i * c.size + c.size / 2);
    if (ROTATE.test(ch)) ctx.rotate(Math.PI / 2);
    else if (PUNCT.test(ch)) ctx.translate(c.size * 0.6, -c.size * 0.6);
    else if (SMALL.test(ch)) ctx.translate(c.size * 0.1, -c.size * 0.1);
    ctx.fillText(ch, 0, 0);
    ctx.restore();
  });
}

async function renderSlip(d: SlipData, family: string) {
  const all = [d.number, d.fortune, ...d.items, ...d.lines, d.shopName, d.year, d.url].join("");
  if (document.fonts) {
    await Promise.all([
      document.fonts.load(`700 144px ${family}`, all),
      document.fonts.load(`500 40px ${family}`, all),
    ]).catch(() => undefined);
  }
  const cv = document.createElement("canvas");
  cv.width = W;
  cv.height = H;
  const ctx = cv.getContext("2d");
  if (!ctx) return null;
  const L = slipLayout(d);
  ctx.fillStyle = PAPER;
  ctx.fillRect(0, 0, W, H);
  ctx.strokeStyle = AMBER;
  ctx.lineWidth = 3;
  ctx.strokeRect(40, 40, W - 80, H - 80);
  ctx.strokeStyle = RULE;
  ctx.lineWidth = 2;
  for (const x of L.rules) {
    ctx.beginPath();
    ctx.moveTo(x, L.ruleTop);
    ctx.lineTo(x, L.ruleBottom);
    ctx.stroke();
  }
  ctx.beginPath();
  ctx.moveTo(96, 1140);
  ctx.lineTo(W - 96, 1140);
  ctx.stroke();
  ctx.strokeStyle = SHU;
  ctx.lineWidth = 3;
  ctx.strokeRect(L.sealBox.x, L.sealBox.y, L.sealBox.w, L.sealBox.h);
  for (const c of L.cols) drawVertical(ctx, c, family);
  ctx.textBaseline = "alphabetic";
  ctx.font = `700 28px ${family}`;
  ctx.fillStyle = INK;
  ctx.textAlign = "left";
  ctx.fillText(d.year, 96, 1204);
  ctx.font = `500 28px ${family}`;
  ctx.textAlign = "right";
  ctx.fillText(d.shopName, W - 96, 1204);
  ctx.fillStyle = MUTED;
  ctx.textAlign = "left";
  ctx.fillText(d.url, 96, 1258);
  const blob = await new Promise<Blob | null>((r) => cv.toBlob(r, "image/png"));
  return blob ? URL.createObjectURL(blob) : null;
}

async function copyText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    ta.remove();
    return ok;
  }
}

const pctX = (v: number) => `${(v / W) * 100}%`;
const pctY = (v: number) => `${(v / H) * 100}%`;
const cqw = (v: number) => `${(v / W) * 100}cqw`;

/** 画面の紙。canvas と同じ配置表から置く */
function Paper({ data }: { data: SlipData }) {
  const L = slipLayout(data);
  const abs: CSSProperties = { position: "absolute" };
  return (
    <div className="@container relative aspect-[1080/1350] w-full select-none" style={{ background: PAPER }}>
      <div aria-hidden="true" className="absolute border-[#d97706]" style={{ left: pctX(40), top: pctY(40), right: pctX(40), bottom: pctY(40), borderWidth: cqw(3) }} />
      {L.rules.map((x) => (
        <div
          key={x}
          aria-hidden="true"
          style={{ ...abs, left: pctX(x - 1), top: pctY(L.ruleTop), width: cqw(2), height: pctY(L.ruleBottom - L.ruleTop), background: RULE }}
        />
      ))}
      <div aria-hidden="true" style={{ ...abs, left: pctX(96), top: pctY(1139), width: pctX(W - 192), height: cqw(2), background: RULE }} />
      <div
        aria-hidden="true"
        style={{ ...abs, left: pctX(L.sealBox.x), top: pctY(L.sealBox.y), width: pctX(L.sealBox.w), height: pctY(L.sealBox.h), border: `${cqw(3)} solid ${SHU}` }}
      />
      {L.cols.map((c) => (
        <div
          key={c.key}
          className={c.key === "fortune" ? "slip-fortune" : undefined}
          style={{
            ...abs,
            left: pctX(c.cx - c.size / 2),
            top: pctY(c.top),
            width: pctX(c.size),
            fontSize: cqw(c.size),
            lineHeight: 1,
            fontWeight: c.weight,
            color: c.color,
            writingMode: "vertical-rl",
            whiteSpace: "nowrap",
            letterSpacing: 0,
          }}
        >
          {c.text}
        </div>
      ))}
      <p style={{ ...abs, left: pctX(96), right: pctX(96), top: pctY(1172), fontSize: cqw(28), lineHeight: 1.6 }} className="flex justify-between gap-4 whitespace-nowrap">
        <span className="font-bold" style={{ color: INK }}>
          {data.year}
        </span>
        <span style={{ color: INK }}>{data.shopName}</span>
      </p>
      <p style={{ ...abs, left: pctX(96), top: pctY(1226), fontSize: cqw(28), lineHeight: 1.6, color: MUTED }} className="whitespace-nowrap">
        {data.url}
      </p>
    </div>
  );
}

/** 六角の筒(自作 SVG・viewBox 120×260)。stick を渡すと穴から番号の棒が出る */
function Tube({ clipId, stickRef, stickNo }: { clipId: string; stickRef?: (el: SVGGElement | null) => void; stickNo?: string }) {
  const s = { vectorEffect: "non-scaling-stroke" as const };
  // 正面に見える3面(左・中・右)。y は上端からの位置
  const band = (y: number, h: number, key: string) => (
    <g key={key}>
      <polygon points={`10,${y} 35,${y + 12} 35,${y + 12 + h} 10,${y + h}`} fill={KINARI} stroke={LINE} strokeWidth={2} style={s} />
      <polygon points={`35,${y + 12} 85,${y + 12} 85,${y + 12 + h} 35,${y + 12 + h}`} fill={KINARI} stroke={LINE} strokeWidth={2} style={s} />
      <polygon points={`85,${y + 12} 110,${y} 110,${y + h} 85,${y + 12 + h}`} fill={KINARI} stroke={LINE} strokeWidth={2} style={s} />
    </g>
  );
  return (
    <svg viewBox="-10 20 140 250" className="h-auto w-full overflow-visible" aria-hidden="true">
      <defs>
        <clipPath id={clipId}>
          <rect x="-10" y="-90" width="140" height="130" />
        </clipPath>
      </defs>
      {/* 上の面と穴 */}
      <polygon points="10,40 35,28 85,28 110,40 85,52 35,52" fill={SHU_LIGHT} stroke={LINE} strokeWidth={2} style={s} />
      <polygon points="50,40 55,36 65,36 70,40 65,44 55,44" fill={LINE} />
      {/* 番号の棒(穴より上だけ見せる) */}
      {stickNo !== undefined && (
        <g clipPath={`url(#${clipId})`}>
          <g ref={stickRef}>
            <rect x="55" y="-70" width="10" height="112" fill={KINARI} stroke={LINE} strokeWidth={2} style={s} />
            <rect x="55" y="-70" width="10" height="14" fill={SHU} stroke={LINE} strokeWidth={2} style={s} />
            {Array.from(stickNo).map((ch, i) => (
              <text key={i} x="60" y={-44 + i * 9} fontSize="8" fontWeight="700" textAnchor="middle" dominantBaseline="central" fill={LINE}>
                {ch}
              </text>
            ))}
          </g>
        </g>
      )}
      {/* 正面の3面 */}
      <polygon points="10,40 35,52 35,252 10,240" fill={SHU_DARK} stroke={LINE} strokeWidth={2} style={s} />
      <polygon points="35,52 85,52 85,252 35,252" fill={SHU} stroke={LINE} strokeWidth={2} style={s} />
      <polygon points="85,52 110,40 110,240 85,252" fill={SHU_LIGHT} stroke={LINE} strokeWidth={2} style={s} />
      {band(56, 10, "top")}
      {band(206, 10, "bottom")}
      {/* 札 */}
      <rect x="45" y="96" width="30" height="96" fill={KINARI} stroke={LINE} strokeWidth={2} style={s} />
      {Array.from("おみくじ").map((ch, i) => (
        <text key={ch} x="60" y={112 + i * 21} fontSize="15" fontWeight="700" textAnchor="middle" dominantBaseline="central" fill={SHU}>
          {ch}
        </text>
      ))}
    </svg>
  );
}

const CODE_EXAMPLE = `import OmikujiSlip from "@/components/first-ch/omikuji-shake-card";

export default function BakeryOmikuji() {
  return (
    <OmikujiSlip
      shopName="パン工房 こむぎ"
      items={["看板のパン", "焼き上がり", "来店によい日", "合う飲み物", "願い事", "待ち人"]}
      results={[
        { fortune: "大吉", lines: ["塩バターロール。二つ買ってよし", "朝七時の第一便", "晴れた土曜の朝", "牛乳。冷たいほうが吉", "思うまま叶う", "来る。パンの香りに誘われて"] },
        { fortune: "吉", lines: ["くるみの田舎パン", "昼前の第二便", "雨の平日。待たずに買える", "深煎りのコーヒー", "時間はかかるが叶う", "遅れて来る"] },
      ]}
      weights={[30, 70]}
      year="2027 未年"
      shareUrl="example.com/omikuji"
    />
  );
}`;

const PROPS_ROWS: [string, string, string][] = [
  ["shopName", "string", "紙と画像の下端・印に入る店名"],
  ["items", "string[]", "項目名6つ(各6字以内)"],
  ["results", "{ fortune, lines }[]", "運勢と各欄の本文(各20字以内)"],
  ["weights", "number[]", "results と同じ順の出やすさ"],
  ["year / shareUrl", "string", "下端の年と URL"],
  ["sticks", "number", "棒の番号の最大。既定は50"],
];

type Phase = "ready" | "stick" | "open";
type Gauge = "idle" | "down" | "up";

export default function OmikujiSlip({
  shopName = "喫茶 ひより",
  items = DEFAULT_ITEMS,
  results = DEFAULT_RESULTS,
  weights = DEFAULT_WEIGHTS,
  year = "2027 未年",
  shareUrl = DEFAULT_URL,
  sticks = 50,
  headingTag,
}: OmikujiSlipProps) {
  const uid = useId();
  const Heading = headingTag ?? "h2";
  const [phase, setPhase] = useState<Phase>("ready");
  const [gauge, setGauge] = useState<Gauge>("idle");
  const [grabbed, setGrabbed] = useState(false);
  const [draw, setDraw] = useState<Draw | null>(null);
  const [image, setImage] = useState<string | null>(null);
  const [message, setMessage] = useState("");
  const [copied, setCopied] = useState(false);

  const tubeRef = useRef<HTMLDivElement>(null);
  const fillRef = useRef<HTMLDivElement>(null);
  const paperRef = useRef<HTMLDivElement>(null);
  const familyRef = useRef<HTMLDivElement>(null);
  const timers = useRef<number[]>([]);
  const imageGen = useRef(0);
  const imageUrl = useRef<string | null>(null);
  const drag = useRef<{ id: number; startY: number; peak: number; stage: Gauge; scale: number } | null>(null);
  const busy = useRef(false);

  useEffect(
    () => () => {
      timers.current.forEach((t) => window.clearTimeout(t));
      if (imageUrl.current) URL.revokeObjectURL(imageUrl.current);
    },
    [],
  );

  const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const later = (fn: () => void, ms: number) => {
    timers.current.push(window.setTimeout(fn, ms));
  };

  const dataOf = (d: Draw): SlipData => ({
    number: `第${kanjiNumber(d.no)}番`,
    fortune: results[d.index]?.fortune ?? "",
    items,
    lines: results[d.index]?.lines ?? [],
    shopName,
    year,
    url: shareUrl,
  });

  const setTube = (y: number, animate: boolean) => {
    const el = tubeRef.current;
    if (!el) return;
    el.style.transition = animate ? "transform 220ms cubic-bezier(.2,.7,.2,1)" : "none";
    el.style.transform = `translate3d(0, ${y}px, 0)`;
  };
  const setFill = (p: number) => {
    if (fillRef.current) fillRef.current.style.transform = `scaleY(${Math.max(0, Math.min(1, p))})`;
  };

  const makeImage = (d: Draw) => {
    const gen = ++imageGen.current;
    const family = familyRef.current ? getComputedStyle(familyRef.current).fontFamily : "sans-serif";
    void renderSlip(dataOf(d), family).then((url) => {
      if (gen !== imageGen.current) {
        if (url) URL.revokeObjectURL(url);
        return;
      }
      if (imageUrl.current) URL.revokeObjectURL(imageUrl.current);
      imageUrl.current = url;
      setImage(url);
    });
  };

  /** 判定した瞬間に呼ぶ。from は振った(gesture)か、ボタン・キー(button)か */
  const drawNow = (from: "gesture" | "button") => {
    if (busy.current || !results.length) return;
    busy.current = true;
    const d: Draw = { no: 1 + Math.floor(Math.random() * Math.max(1, Math.min(99, sticks))), index: pickWeighted(weights, results.length) };
    const label = `第${kanjiNumber(d.no)}番 ${results[d.index].fortune}`;
    setDraw(d);
    setImage(null);
    makeImage(d);
    drag.current = null;
    setGauge("idle");
    setGrabbed(false);
    setFill(0);
    if (reduced()) {
      setPhase("open");
      setMessage(`${label}を引きました`);
      busy.current = false;
      return;
    }
    setMessage("番号の棒が出ました");
    const tube = tubeRef.current;
    if (from === "button" && tube) {
      // ボタン・キーで引いたときも、振った動きを短く見せる
      tube.style.transition = "none";
      tube.animate(
        [
          { transform: "translate3d(0,0,0)" },
          { transform: "translate3d(0,44px,0)", offset: 0.35 },
          { transform: "translate3d(0,-14px,0)", offset: 0.7 },
          { transform: "translate3d(0,0,0)" },
        ],
        { duration: 360, easing: "ease-in-out" },
      );
    } else setTube(0, true);
    setPhase("stick");
    later(() => {
      setPhase("open");
      setMessage(`${label}を引きました`);
      busy.current = false;
    }, from === "button" ? 860 : 620);
  };

  const stickRef = (el: SVGGElement | null) => {
    if (!el || el.dataset.go) return;
    el.dataset.go = "1";
    el.animate([{ transform: "translate(0, 110px)" }, { transform: "translate(0, 0)" }], {
      duration: 300,
      easing: "cubic-bezier(.2,.7,.2,1)",
      fill: "backwards",
    });
  };
  const paperOpenRef = (el: HTMLDivElement | null) => {
    paperRef.current = el;
    if (!el || el.dataset.go || reduced()) return;
    el.dataset.go = "1";
    el.animate([{ transform: "translateY(-100%)" }, { transform: "translateY(0)" }], { duration: OPEN_MS, easing: "cubic-bezier(.2,.7,.2,1)" });
  };

  const onDown = (e: PointerEvent<HTMLDivElement>) => {
    if (phase !== "ready" || busy.current) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    const r = e.currentTarget.getBoundingClientRect();
    drag.current = { id: e.pointerId, startY: e.clientY, peak: 0, stage: "down", scale: r.height / (e.currentTarget.offsetHeight || r.height) || 1 };
    setGrabbed(true);
    setGauge("down");
    setFill(0);
    setTube(0, false);
  };
  const onMove = (e: PointerEvent<HTMLDivElement>) => {
    const g = drag.current;
    if (!g || g.id !== e.pointerId) return;
    const dy = (e.clientY - g.startY) / g.scale;
    // 指に追従(下は120pxを超えると重くなる)
    const vis = dy > 120 ? 120 + (dy - 120) * 0.3 : dy < -40 ? -40 + (dy + 40) * 0.3 : dy;
    setTube(vis, false);
    g.peak = Math.max(g.peak, dy);
    if (g.stage === "down") {
      setFill(g.peak / SWING / 2);
      if (g.peak >= SWING) {
        g.stage = "up";
        setGauge("up");
      }
    } else {
      const back = g.peak - dy;
      setFill(0.5 + back / SWING / 2);
      if (back >= SWING) drawNow("gesture");
    }
  };
  const onUp = (e: PointerEvent<HTMLDivElement>) => {
    const g = drag.current;
    if (!g || g.id !== e.pointerId) return;
    drag.current = null;
    setTube(0, true);
    setFill(0);
    setGauge("idle");
    setGrabbed(false);
    if (g.peak > 8) setMessage("途中で離したので、筒を元に戻しました。下へ、上へと1往復させてください");
  };
  const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === " " || e.key === "Enter") {
      e.preventDefault();
      drawNow("button");
    }
  };

  const again = () => {
    timers.current.forEach((t) => window.clearTimeout(t));
    timers.current = [];
    busy.current = false;
    imageGen.current++;
    setPhase("ready");
    setDraw(null);
    setImage(null);
    setMessage("筒に戻しました。もう一度振れます");
    later(() => tubeRef.current?.focus({ preventScroll: true }), 0);
  };

  const doCopy = async () => {
    const ok = await copyText(CODE_EXAMPLE);
    setCopied(ok);
    if (ok) setMessage("コードをコピーしました");
  };

  const total = results.reduce((a, _, i) => a + Math.max(0, weights[i] ?? 0), 0) || 1;
  const data = draw ? dataOf(draw) : null;
  const btn =
    "inline-flex min-h-11 items-center justify-center border px-4 text-sm font-bold outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] motion-reduce:transition-none";

  return (
    <section className="relative mx-auto w-full max-w-6xl border border-stone-200 bg-white text-stone-900" aria-labelledby={`${uid}-title`}>
      <div ref={familyRef} aria-hidden="true" className="hidden" />
      {/* 見出し */}
      <div className="max-w-4xl px-5 py-4 sm:px-8 sm:py-5">
        <p className="hidden font-display text-xs font-semibold tracking-[0.18em] text-stone-500 sm:block">
          BLK-107<span className="mx-2 inline-block h-px w-5 bg-stone-300 align-middle" />
          <span className="font-sans tracking-normal">店のおみくじ</span>
        </p>
        <Heading id={`${uid}-title`} className="text-[clamp(1.15rem,3.2vw,2rem)] font-bold leading-[1.4] tracking-tight text-balance [word-break:auto-phrase] sm:mt-3">
          筒を振って引く、<span className="whitespace-nowrap">{shopName}のおみくじ</span>
        </Heading>
      </div>

      {/* 筒と紙。狭い幅では、引いた後は 結果の操作 → 紙 → 保存画像の見本 の順に並べる(保存・再抽選を紙の上に出す) */}
      <div className="grid border-t border-stone-200 lg:grid-cols-[minmax(0,1fr)_18rem] lg:grid-rows-[auto_minmax(0,1fr)]">
        <div className="relative order-2 min-w-0 border-stone-200 px-5 py-5 sm:px-8 sm:py-6 lg:order-none lg:col-start-1 lg:row-span-2 lg:row-start-1 lg:border-r">
          {phase !== "open" || !data ? (
            <div className="flex flex-col items-center">
              <div className="relative mt-11 flex items-start gap-3 sm:mt-14 sm:gap-6">
                {/* 結果の見本 */}
                <figure className="-mt-5 w-[84px] sm:-mt-6 sm:w-[112px]" aria-hidden="true">
                  <div className="-rotate-3 border border-stone-200 shadow-[0_1px_0_#e7e5e4]">
                    <Paper data={{ ...SAMPLE, fortune: results[0]?.fortune ?? "", items, lines: results[0]?.lines ?? [], shopName, year, url: shareUrl }} />
                  </div>
                  <figcaption className="mt-2.5 text-center text-[11px] leading-snug text-stone-500 sm:mt-3 sm:text-xs">引くと開く紙</figcaption>
                </figure>
                <div
                  ref={tubeRef}
                  role="button"
                  tabIndex={0}
                  aria-label="おみくじの筒。上下に1往復振ると引けます。Space キーでも引けます"
                  aria-describedby={`${uid}-how`}
                  onPointerDown={onDown}
                  onPointerMove={onMove}
                  onPointerUp={onUp}
                  onPointerCancel={onUp}
                  onKeyDown={onKey}
                  className={`omikuji-tube relative z-10 w-[84px] cursor-grab touch-none select-none outline-none will-change-transform focus-visible:outline-2 focus-visible:outline-offset-8 focus-visible:outline-[#d97706] sm:w-[120px] ${
                    grabbed ? "cursor-grabbing" : ""
                  }`}
                >
                  <div className={`transition-transform duration-150 motion-reduce:transition-none ${grabbed ? "scale-[1.04]" : ""}`}>
                    <Tube clipId={`${uid}-clip`} stickRef={stickRef} stickNo={phase === "stick" && draw ? kanjiNumber(draw.no) : undefined} />
                  </div>
                </div>
                {/* 往復の目盛り。引く前から案内として見せ、つかむと上から下へ満ちる: 前半=下へ、後半=上へ戻す */}
                <div aria-hidden="true" className="mt-1 flex items-stretch gap-2.5">
                  <div className="relative h-36 w-[3px] bg-stone-200 sm:h-44">
                    <div ref={fillRef} className="absolute inset-0 origin-top bg-[#d97706]" style={{ transform: "scaleY(0)" }} />
                    {Array.from({ length: 9 }, (_, i) => (
                      <span key={i} className={`absolute -left-1.5 h-px bg-stone-400 ${i % 4 === 0 ? "w-4" : "w-2.5"}`} style={{ top: `${(i / 8) * 100}%` }} />
                    ))}
                  </div>
                  <div className="relative h-36 w-[4.5rem] text-xs font-bold leading-tight sm:h-44">
                    <span className={`absolute top-0 -translate-y-1/2 transition-colors ${gauge === "down" ? "text-[#b45309]" : gauge === "up" ? "text-stone-400" : "text-stone-600"}`}>
                      ↓ 下へ80px
                    </span>
                    <span className={`absolute top-1/2 -translate-y-1/2 transition-colors ${gauge === "up" ? "text-[#b45309]" : gauge === "idle" ? "text-stone-600" : "text-stone-400"}`}>
                      ↑ 上へ80px
                    </span>
                    <span className="absolute top-full -translate-y-1/2 text-stone-400">1往復で引く</span>
                  </div>
                </div>
              </div>
              <div className={`mt-auto flex flex-col items-center transition-opacity duration-150 motion-reduce:transition-none ${grabbed ? "opacity-0" : "opacity-100"}`}>
                <p id={`${uid}-how`} className="pt-5 text-center text-sm leading-relaxed text-balance text-stone-600 [word-break:auto-phrase]">
                  筒をつかんで下へ、そのまま上へ。1往復で1枚引けます
                </p>
                <div className="mt-3 flex items-center gap-3">
                  <button
                    type="button"
                    onClick={() => drawNow("button")}
                    disabled={phase !== "ready"}
                    className={`${btn} draw min-w-28 border-[#d97706] bg-[#d97706] text-white hover:bg-amber-700 disabled:opacity-60`}
                  >
                    引く
                  </button>
                  <span className="hidden text-xs text-stone-500 sm:inline">
                    筒を選んで <kbd className="border border-stone-300 px-1.5 py-0.5 font-mono text-[11px]">Space</kbd> でも引けます
                  </span>
                </div>
              </div>
            </div>
          ) : (
            <div className="mx-auto w-full max-w-[440px]">
              <div className="h-1 bg-[#c73e1d]" aria-hidden="true" />
              <div className="overflow-hidden border-x border-b border-stone-200">
                <div ref={paperOpenRef} role="img" aria-label={`${data.number} ${data.fortune}。${items.map((it, i) => `${it}: ${data.lines[i] ?? ""}`).join("。")}`}>
                  <Paper data={data} />
                </div>
              </div>
            </div>
          )}
        </div>

        {phase === "open" && data ? (
          <>
            {/* 結果の操作(狭い幅では紙の上) */}
            <div className="order-1 min-w-0 border-b border-stone-200 px-5 py-4 sm:px-8 sm:py-6 lg:order-none lg:col-start-2 lg:row-start-1 lg:border-b-0 lg:pb-0">
              <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-3 lg:block">
                <div>
                  <p className="text-xs text-stone-500 sm:text-sm">引いた結果</p>
                  <p className="mt-0.5 text-2xl font-bold">
                    {data.number}
                    <span className="ml-3 text-[#b45309]">{data.fortune}</span>
                  </p>
                </div>
                <div className="flex flex-wrap gap-2 lg:mt-5">
                  {image ? (
                    <a href={image} download={`omikuji-${draw?.no ?? 0}.png`} className={`${btn} save border-[#d97706] bg-[#d97706] text-white hover:bg-amber-700`}>
                      画像を保存
                    </a>
                  ) : (
                    <span className={`${btn} border-stone-200 bg-stone-50 text-stone-400`}>画像を作成中</span>
                  )}
                  <button type="button" onClick={again} className={`${btn} again border-stone-900 bg-white text-stone-900 hover:bg-stone-50`}>
                    もう一度引く
                  </button>
                </div>
              </div>
            </div>
            {/* 保存される画像の見本(狭い幅では紙の下) */}
            <div className="order-3 min-w-0 border-t border-stone-200 px-5 py-5 sm:px-8 sm:py-6 lg:order-none lg:col-start-2 lg:row-start-2 lg:border-t-0 lg:pt-6">
              <div className="flex items-start gap-4 lg:border-t lg:border-stone-200 lg:pt-5">
                <div className="w-24 shrink-0 border border-stone-200 bg-stone-50 sm:w-28">
                  {image ? (
                    // eslint-disable-next-line @next/next/no-img-element
                    <img src={image} alt={`保存される画像。${data.number} ${data.fortune}`} width={1080} height={1350} className="block h-auto w-full" />
                  ) : (
                    <div className="aspect-[1080/1350]" />
                  )}
                </div>
                <p className="text-xs leading-relaxed text-stone-500">
                  保存される画像(1080×1350 PNG)。画面の紙と同じ配置で、端末の中で作ります。どこにも送信しません。
                </p>
              </div>
            </div>
          </>
        ) : (
          /* 店の設定(引く前) */
          <div className="order-3 min-w-0 border-t border-stone-200 px-5 py-6 sm:px-8 lg:order-none lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:border-t-0">
            <div>
              <p className="text-sm font-bold">紙に載る6項目</p>
              <ol className="mt-3 grid grid-cols-2 gap-x-4 border-t border-stone-200">
                {items.slice(0, 6).map((it, i) => (
                  <li key={it} className="flex items-baseline gap-2 border-b border-stone-200 py-2 text-sm">
                    <span className="font-display text-xs text-stone-400">{i + 1}</span>
                    {it}
                  </li>
                ))}
              </ol>
              <p className="mt-6 text-sm font-bold">運勢の出やすさ</p>
              <ul className="mt-3 grid gap-2">
                {results.map((r, i) => {
                  const p = Math.round((Math.max(0, weights[i] ?? 0) / total) * 100);
                  return (
                    <li key={r.fortune} className="grid grid-cols-[3rem_minmax(0,1fr)_2.5rem] items-center gap-2 text-sm">
                      <span>{r.fortune}</span>
                      <span className="h-1.5 bg-stone-100">
                        <span className="block h-full origin-left bg-[#d97706]" style={{ transform: `scaleX(${p / 100})` }} />
                      </span>
                      <span className="text-right font-display text-xs tabular-nums text-stone-500">{p}%</span>
                    </li>
                  );
                })}
              </ul>
              <p className="mt-4 text-xs leading-relaxed text-stone-500">凶は入れていません。重みと文例は props で店ごとに変えられます。</p>
            </div>
          </div>
        )}
      </div>

      <p className="sr-only" aria-live="polite">
        {message}
      </p>

      {/* 動きの見本 */}
      <div className="border-t border-stone-200 px-5 py-6 sm:px-8">
        <p className="text-sm font-bold">指で動かすほかの見本</p>
        <ul className="mt-3 grid gap-x-8 sm:grid-cols-2">
          {MOTION_LINKS.map((l) => (
            <li key={l.href} className="border-b border-stone-200">
              <a
                href={l.href}
                className="group flex min-h-12 flex-col justify-center py-2 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706]"
              >
                <span className="text-sm font-bold text-stone-900 underline decoration-stone-300 underline-offset-4 group-hover:decoration-[#d97706]">{l.title}</span>
                <span className="mt-0.5 text-xs text-stone-500">{l.note}</span>
              </a>
            </li>
          ))}
        </ul>
      </div>

      {/* 組み込み */}
      <div className="grid gap-6 border-t border-stone-200 px-5 py-7 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <div>
          <h3 className="text-base font-bold">自分の店の項目に差し替える</h3>
          <p className="mt-2 text-sm leading-relaxed text-stone-600">項目名・運勢・文例・出やすさを渡すだけで、紙と画像の両方が変わります。</p>
          <table className="mt-4 w-full border-t border-stone-200 text-left text-[13px]">
            <tbody>
              {PROPS_ROWS.map(([name, type, note]) => (
                <tr key={name} className="border-b border-stone-200 align-top">
                  <th scope="row" className="py-2 pr-3 font-mono text-[12px] font-medium text-stone-900">
                    {name}
                  </th>
                  <td className="hidden py-2 pr-3 font-mono text-[12px] text-stone-500 sm:table-cell">{type}</td>
                  <td className="py-2 text-stone-600">{note}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <div className="min-w-0">
          <div className="flex items-center justify-between border border-b-0 border-stone-200 px-3 py-2">
            <span className="font-mono text-xs text-stone-500">bakery-omikuji.tsx</span>
            <button type="button" onClick={doCopy} className={`${btn} copy-code min-h-9 border-stone-300 bg-white px-3 text-xs text-stone-900 hover:border-[#d97706]`}>
              {copied ? "コピーしました" : "コードをコピー"}
            </button>
          </div>
          <pre className="max-h-80 overflow-auto border border-stone-200 bg-stone-950 p-4 font-mono text-[12px] leading-relaxed text-stone-100">
            <code>{CODE_EXAMPLE}</code>
          </pre>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/omikuji-shake-card.json