懐中電灯エフェクトで遊ぶ隠しキーワード探し|React・CSSコード付き
店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。暗い場面の上をマウスや指でなぞると半径110pxの光の輪がついてきて、隠れた文字の上で400ms止まると進捗リングが満ち、文字が上部の5マスへ入ります。文字は8か所の候補から毎回5か所を選ぶので、位置を覚えても解けません。5文字そろうと計時が止まり、秒数・もう一度のボタン・共有用の画像(1200×630・合言葉は伏せ字)とコピー文が出ます。画像は端末の中で作り、どこにも送信しません。既定の場面はハロウィンの夜の駄菓子屋を描いた自作SVGで、場面・文字・候補位置をpropsで渡せば年末やクリスマスの企画にも使えます。矢印キーでも遊べて、prefers-reduced-motion では追従の補間と文字の飛ぶ動きを止めます。依存ライブラリなし。
プレビュー
BLK-105動き・季節キャンペーン
夜の駄菓子屋に隠れた5文字を、懐中電灯で探す
- 未発見
- 未発見
- 未発見
- 未発見
- 未発見
経過0.0秒
なぞると計時が始まります
なぞると懐中電灯が照らします。まずは軒下の灯りの横から
店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。場面の上をなぞると光の輪がついてきて、文字の上で少し止まると上のマスに入ります。文字の場所は毎回変わります。
マウス・指: 場面の上をなぞる(指を離すと判定が外れます)/キーボード: 場面を選んで矢印キー、Shift で大きく動かす
結果を共有する
5文字そろうと、秒数と共有用の画像(1200×630)がここに出ます。画像は端末の中で作り、どこにも送信しません。
年末やクリスマスの企画に差し替える
場面の SVG・文字・候補位置を渡せば、同じ仕組みで別の季節に使えます。
| keyword | string | 探させる文字列。既定は5文字 |
|---|---|---|
| scene | ReactNode | 場面の SVG(viewBox 960×640) |
| spots | { x, y, rotate? }[] | 候補位置(%)。文字数より多く |
| lantern | { x, y, spot, radius? } | 最初から灯る明かりと、毎回使う候補 |
| radius | number | 光の輪の半径。既定 110px |
| dwellMs | number | 取得までの時間。既定 400ms |
| title / shareUrl | string | 共有画像とコピー文に載せる作品名と URL |
import FlashlightReveal from "@/components/halloween-flashlight-reveal";
import { WinterArcadeScene } from "./winter-arcade-scene"; // viewBox 960×640 の SVG
export default function YearEndCampaign() {
return (
<FlashlightReveal
keyword="ふくびきけん"
scene={<WinterArcadeScene />}
spots={[
{ x: 22, y: 48 }, { x: 34, y: 72, rotate: 4 }, { x: 51, y: 40 },
{ x: 63, y: 76 }, { x: 78, y: 52 }, { x: 88, y: 30, rotate: -6 },
{ x: 12, y: 80 }, { x: 45, y: 20 }, { x: 70, y: 22 },
]}
lantern={{ x: 14, y: 40, spot: 0, radius: 0.11 }}
radius={110}
dwellMs={400}
title="年末の商店街で合言葉さがし"
shareUrl="https://example.com/campaign/year-end"
/>
);
}説明の全文を読む
店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。暗い場面の上をマウスや指でなぞると半径110pxの光の輪がついてきて、隠れた文字の上で400ms止まると進捗リングが満ち、文字が上部の5マスへ入ります。文字は8か所の候補から毎回5か所を選ぶので、位置を覚えても解けません。5文字そろうと計時が止まり、秒数・もう一度のボタン・共有用の画像(1200×630・合言葉は伏せ字)とコピー文が出ます。画像は端末の中で作り、どこにも送信しません。既定の場面はハロウィンの夜の駄菓子屋を描いた自作SVGで、場面・文字・候補位置をpropsで渡せば年末やクリスマスの企画にも使えます。矢印キーでも遊べて、prefers-reduced-motion では追従の補間と文字の飛ぶ動きを止めます。依存ライブラリなし。
- 追加日:
- 2026-10-27
- 依存:
- なし
- tags
- #block #motion #spotlight #flashlight #hidden-word #campaign #halloween #seasonal #svg-mask #canvas #share-image #pointer-events #keyboard #reduced-motion #hairline #no-dependency
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
/**
* 懐中電灯エフェクトの隠しキーワード探し
*
* 差別化: 既存の円形の演出(theme-switch-reveal の配色切り替え)は一度きりの画面遷移で、3D や検索の部品(product-configurator-3d・command-palette-search)は眺める・選ぶための道具。
* 本ブロックは光の輪で場面を探る遊びとして、判定・進捗リング・計時・結果画像までを「なぞる」1操作で完結させた。
* 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・読み込み時の出現アニメは置かず、hairline 罫線と余白で組んでいる。
*
* 汎用技法メモ:
* 1. **暗闇は SVG の mask で抜く**。場面の上に紺の矩形(不透明度 0.92 = 明るさ 8%)を重ね、mask の中の円を transform(translate)だけで動かす。
* 円の塗りは radialGradient で、中心から (半径 − 24px) までは黒の不透明、縁で透明へ落とす。stop-opacity で抜くので、
* 懐中電灯と軒下の灯りの2つの円が重なっても互いの縁で塗りつぶし合わない(白→黒の色の補間で作ると後から描いた円の縁が前の円を消す)。
* 2. **追従は時間基準の補間**。毎フレーム 1 − exp(−dt/60ms) の割合で目標へ寄せるので、フレームが落ちる端末でも同じ速さで追いつく。
* 動き終わって判定中の文字も無ければ requestAnimationFrame を止め、一覧の小さなプレビューでも回り続けない。
* 3. **判定は文字の中心との距離**。輪の半径の半分以内(小さな画面では文字の大きさ以上)に入った瞬間に輪郭を灯し、
* 400ms 留まると取得。進捗リングの stroke-dashoffset は React の state を通さず ref で直接書き換える(毎フレームの再描画を避ける)。
* 4. **飛ぶ動きは Web Animations の transform だけ**。場面の文字と上部のマスの getBoundingClientRect の差を translate と scale にする。
* 5. **共有画像は端末の中で作る**。場面の SVG を XMLSerializer で文字列にして Blob URL から canvas に描き、暗くした上で丸く抜く。
* 隠し文字は SVG の外(HTML)に置いているので画像に写らない。合言葉は「?」で伏せる。どこへも送信しない。
*/
const AMBER = "#d97706";
const NAVY = "#151d36";
const NAVY_DEEP = "#0d1328";
const WALL = "#1c2647";
const ORANGE = "#e8873a";
const CREAM = "#efe6d2";
const GREEN = "#7d9078";
const DARK = "#05070f";
const SCENE_W = 960;
const SCENE_H = 640;
export type FlashlightSpot = { x: number; y: number; rotate?: number };
export type FlashlightLantern = { x: number; y: number; spot: number; radius?: number };
export type FlashlightRevealProps = {
/** 探させる文字列。1文字ずつ候補位置へ散らす */
keyword?: string;
/** 場面の SVG。viewBox は 960×640(3:2)で描く */
scene?: ReactNode;
/** 文字の候補位置(場面に対する %)。keyword の文字数より多く用意する */
spots?: FlashlightSpot[];
/** 最初から灯っている明かり(%)。spot に指定した候補は毎回かならず使い、その文字の端だけを照らす */
lantern?: FlashlightLantern;
/** 光の輪の半径(px)。狭い画面では場面の幅に合わせて小さくなる */
radius?: number;
/** 文字の上に留まって取得するまでの時間(ms) */
dwellMs?: number;
/** 共有画像に載せる作品名 */
title?: string;
/** 共有画像とコピー文に載せる作品ページの URL */
shareUrl?: string;
/** 見出しの要素。ページの主見出しにするときだけ "h1" */
headingTag?: "h1" | "h2" | "p";
};
const DEFAULT_SPOTS: FlashlightSpot[] = [
{ x: 26.9, y: 46.3, rotate: -4 },
{ x: 35.9, y: 70.6, rotate: 3 },
{ x: 55.2, y: 46.9, rotate: -2 },
{ x: 67.2, y: 76.3, rotate: 2 },
{ x: 86.3, y: 72.5, rotate: -6 },
{ x: 7.3, y: 81.6, rotate: 5 },
{ x: 92.1, y: 41.4, rotate: 8 },
{ x: 56.3, y: 22, rotate: 0 },
];
const DEFAULT_LANTERN: FlashlightLantern = { x: 17.7, y: 44.1, spot: 0, radius: 0.11 };
const START_LIGHT = { x: 40, y: 23 };
const DEFAULT_URL = "https://designs.first-ch.com/d/halloween-flashlight-reveal";
/** 既定の場面: ハロウィンの夜の駄菓子屋。線幅 2px・紺地に橙・生成り・くすんだ緑 */
export function DagashiNightScene() {
const line = { stroke: CREAM, strokeWidth: 2, vectorEffect: "non-scaling-stroke" as const, strokeLinejoin: "round" as const, strokeLinecap: "round" as const };
const jars = (y: number, xs: number[], w = 46) =>
xs.map((x, i) => (
<g key={`${y}-${x}`}>
<rect x={x} y={y - 54} width={w} height={54} rx={8} fill={NAVY} {...line} />
<rect x={x + 6} y={y - 62} width={w - 12} height={9} fill={GREEN} {...line} />
<circle cx={x + w * 0.3} cy={y - 16} r={6} fill={i % 2 ? ORANGE : GREEN} />
<circle cx={x + w * 0.62} cy={y - 22} r={6} fill={i % 2 ? CREAM : ORANGE} />
<circle cx={x + w * 0.45} cy={y - 34} r={5} fill={i % 2 ? GREEN : CREAM} />
</g>
));
return (
<svg
data-scene=""
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${SCENE_W} ${SCENE_H}`}
width={SCENE_W}
height={SCENE_H}
preserveAspectRatio="xMidYMid slice"
style={{ display: "block", width: "100%", height: "100%" }}
>
<rect width={SCENE_W} height={SCENE_H} fill={NAVY} />
{[
[60, 60], [140, 34], [250, 70], [560, 40], [700, 22], [910, 160], [780, 190], [40, 210], [940, 40],
].map(([x, y]) => (
<circle key={`${x}-${y}`} cx={x} cy={y} r={2} fill={CREAM} opacity={0.7} />
))}
<circle cx={830} cy={92} r={36} fill={CREAM} />
<circle cx={846} cy={82} r={32} fill={NAVY} />
{[
[596, 66, 1],
[636, 44, 0.8],
].map(([x, y, s]) => (
<path
key={x}
transform={`translate(${x} ${y}) scale(${s})`}
d="M0 6 Q8 -6 16 4 Q20 -2 24 4 Q28 -2 32 4 Q40 -6 48 6 Q38 4 32 14 Q28 8 24 12 Q20 8 16 14 Q10 4 0 6 Z"
fill={NAVY_DEEP}
{...line}
/>
))}
<rect x={0} y={560} width={SCENE_W} height={80} fill={NAVY_DEEP} />
<line x1={0} y1={560} x2={SCENE_W} y2={560} {...line} stroke={GREEN} />
{[80, 220, 380, 540, 700, 860].map((x) => (
<line key={x} x1={x} y1={600} x2={x + 60} y2={600} {...line} stroke={GREEN} />
))}
{/* 建物 */}
<rect x={120} y={176} width={640} height={384} fill={WALL} {...line} />
<path d="M96 182 L162 108 L718 108 L784 182 Z" fill={NAVY_DEEP} {...line} />
<line x1={140} y1={134} x2={740} y2={134} {...line} stroke={GREEN} />
<line x1={118} y1={158} x2={762} y2={158} {...line} stroke={GREEN} />
<rect x={322} y={116} width={284} height={50} fill={WALL} {...line} stroke={ORANGE} />
<g transform="translate(384 141)">
<ellipse rx={26} ry={13} fill={ORANGE} {...line} />
<path d="M-26 0 L-42 -12 L-42 12 Z M26 0 L42 -12 L42 12 Z" fill={ORANGE} {...line} />
<line x1={-8} y1={-12} x2={-8} y2={12} {...line} />
<line x1={8} y1={-12} x2={8} y2={12} {...line} />
</g>
{/* 屋根の猫 */}
<g transform="translate(690 108)">
<path d="M-18 0 Q-22 -26 -6 -30 L6 -30 Q20 -26 16 0 Z" fill={NAVY_DEEP} {...line} />
<path d="M-12 -30 L-14 -46 L-4 -38 L4 -38 L12 -46 L10 -30" fill={NAVY_DEEP} {...line} />
<path d="M16 -4 Q34 -2 30 -22" fill="none" {...line} />
<circle cx={-4} cy={-34} r={2} fill={ORANGE} />
<circle cx={4} cy={-34} r={2} fill={ORANGE} />
</g>
{/* 縞の庇 */}
{Array.from({ length: 14 }, (_, i) => (
<rect key={i} x={104 + i * 48} y={194} width={48} height={34} fill={i % 2 ? NAVY_DEEP : ORANGE} />
))}
<rect x={104} y={194} width={672} height={34} fill="none" {...line} />
<path
d={`M104 228 ${Array.from({ length: 14 }, (_, i) => `Q${128 + i * 48} 246 ${152 + i * 48} 228`).join(" ")}`}
fill={NAVY_DEEP}
{...line}
/>
{/* 左の飾り棚 */}
<rect x={150} y={252} width={290} height={270} fill={NAVY_DEEP} {...line} />
{[330, 410, 488].map((y) => (
<line key={y} x1={150} y1={y} x2={440} y2={y} {...line} />
))}
{jars(330, [168, 316, 382])}
{jars(410, [168, 234, 300, 366], 44)}
{jars(488, [168, 230, 392], 40)}
<line x1={295} y1={496} x2={295} y2={516} {...line} />
{/* 入口と暖簾 */}
<rect x={470} y={252} width={270} height={308} fill={NAVY_DEEP} {...line} />
{[0, 1, 2].map((i) => (
<rect key={i} x={478 + i * 86} y={252} width={80} height={92} fill={GREEN} {...line} />
))}
<circle cx={605} cy={300} r={18} fill="none" {...line} />
<rect x={570} y={430} width={160} height={130} fill={WALL} {...line} />
<line x1={560} y1={430} x2={740} y2={430} {...line} stroke={ORANGE} />
<rect x={590} y={404} width={34} height={26} fill={ORANGE} {...line} />
<rect x={682} y={410} width={30} height={20} fill={GREEN} {...line} />
{/* 軒下の提灯 */}
<line x1={170} y1={240} x2={170} y2={252} {...line} />
<rect x={156} y={250} width={28} height={8} fill={NAVY_DEEP} {...line} />
<ellipse cx={170} cy={282} rx={22} ry={26} fill={ORANGE} {...line} />
{[-10, 0, 10].map((d) => (
<line key={d} x1={150} y1={282 + d} x2={190} y2={282 + d} {...line} stroke={NAVY_DEEP} />
))}
<rect x={156} y={306} width={28} height={8} fill={NAVY_DEEP} {...line} />
{/* 外の木箱 */}
<rect x={28} y={478} width={86} height={82} fill={NAVY_DEEP} {...line} />
<line x1={28} y1={498} x2={114} y2={498} {...line} />
<line x1={28} y1={544} x2={114} y2={544} {...line} />
{/* かぼちゃ */}
{[
[470, 538, 40, 26],
[910, 542, 30, 20],
].map(([x, y, rx, ry]) => (
<g key={x}>
<ellipse cx={x} cy={y} rx={rx} ry={ry} fill={ORANGE} {...line} />
<ellipse cx={x} cy={y} rx={rx * 0.45} ry={ry} fill="none" {...line} stroke={NAVY_DEEP} />
<path d={`M${x} ${y - ry} q2 -10 8 -12`} fill="none" {...line} stroke={GREEN} />
<path
d={`M${x - rx * 0.5} ${y - 4} l${rx * 0.18} ${-ry * 0.4} l${rx * 0.18} ${ry * 0.4} Z M${x + rx * 0.14} ${y - 4} l${rx * 0.18} ${-ry * 0.4} l${rx * 0.18} ${ry * 0.4} Z M${x - rx * 0.45} ${y + ry * 0.3} q${rx * 0.45} ${ry * 0.4} ${rx * 0.9} 0`}
fill={NAVY_DEEP}
{...line}
stroke={NAVY_DEEP}
/>
</g>
))}
{/* ガチャの機械 */}
<rect x={792} y={500} width={72} height={60} fill={ORANGE} {...line} />
<circle cx={828} cy={530} r={9} fill={NAVY_DEEP} {...line} />
<circle cx={828} cy={464} r={38} fill={NAVY_DEEP} {...line} />
{[
[808, 488, ORANGE],
[826, 492, GREEN],
[846, 486, CREAM],
].map(([x, y, c]) => (
<circle key={x as number} cx={x as number} cy={y as number} r={7} fill={c as string} />
))}
{/* おばけ */}
<path
d="M856 300 Q852 220 884 214 Q916 220 912 300 L902 290 L892 302 L882 290 L872 302 L864 290 Z"
fill={NAVY_DEEP}
{...line}
/>
<circle cx={872} cy={236} r={3} fill={CREAM} />
<circle cx={894} cy={236} r={3} fill={CREAM} />
</svg>
);
}
const reduceStore = {
subscribe: (cb: () => void) => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
},
get: () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
};
const noopSubscribe = () => () => {};
const serverFalse = () => false;
const clientTrue = () => true;
type Round = number[]; // 文字の順番 → 候補位置の番号
function makeRound(count: number, spotCount: number, fixed: number): Round {
const others = Array.from({ length: spotCount }, (_, i) => i).filter((i) => i !== fixed);
for (let i = others.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[others[i], others[j]] = [others[j], others[i]];
}
const chosen = [fixed, ...others.slice(0, count - 1)];
for (let i = chosen.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[chosen[i], chosen[j]] = [chosen[j], chosen[i]];
}
return chosen;
}
type Flyer = { id: number; index: number; char: string; x: number; y: number; dx: number; dy: number; scale: number; size: number };
const loadImage = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
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 PROPS_EXAMPLE = `import FlashlightReveal from "@/components/halloween-flashlight-reveal";
import { WinterArcadeScene } from "./winter-arcade-scene"; // viewBox 960×640 の SVG
export default function YearEndCampaign() {
return (
<FlashlightReveal
keyword="ふくびきけん"
scene={<WinterArcadeScene />}
spots={[
{ x: 22, y: 48 }, { x: 34, y: 72, rotate: 4 }, { x: 51, y: 40 },
{ x: 63, y: 76 }, { x: 78, y: 52 }, { x: 88, y: 30, rotate: -6 },
{ x: 12, y: 80 }, { x: 45, y: 20 }, { x: 70, y: 22 },
]}
lantern={{ x: 14, y: 40, spot: 0, radius: 0.11 }}
radius={110}
dwellMs={400}
title="年末の商店街で合言葉さがし"
shareUrl="https://example.com/campaign/year-end"
/>
);
}`;
const PROPS_ROWS: [string, string, string][] = [
["keyword", "string", "探させる文字列。既定は5文字"],
["scene", "ReactNode", "場面の SVG(viewBox 960×640)"],
["spots", "{ x, y, rotate? }[]", "候補位置(%)。文字数より多く"],
["lantern", "{ x, y, spot, radius? }", "最初から灯る明かりと、毎回使う候補"],
["radius", "number", "光の輪の半径。既定 110px"],
["dwellMs", "number", "取得までの時間。既定 400ms"],
["title / shareUrl", "string", "共有画像とコピー文に載せる作品名と URL"],
];
export default function FlashlightReveal({
keyword = "おばけだぞ",
scene,
spots = DEFAULT_SPOTS,
lantern = DEFAULT_LANTERN,
radius = 110,
dwellMs = 400,
title = "夜の駄菓子屋の隠しキーワード探し",
shareUrl = DEFAULT_URL,
headingTag,
}: FlashlightRevealProps) {
const Heading = headingTag === "h1" ? "h1" : "h2";
const chars = Array.from(keyword);
const count = chars.length;
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const reduce = useSyncExternalStore(reduceStore.subscribe, reduceStore.get, serverFalse);
const hydrated = useSyncExternalStore(noopSubscribe, clientTrue, serverFalse);
const [size, setSize] = useState({ w: 0, h: 0 });
const [round, setRound] = useState<Round>(() => makeRound(count, spots.length, lantern.spot));
const [found, setFound] = useState<boolean[]>(() => chars.map(() => false));
const [landed, setLanded] = useState<boolean[]>(() => chars.map(() => false));
const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
const [elapsed, setElapsed] = useState(0);
const [active, setActive] = useState<number | null>(null);
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [share, setShare] = useState<{ url: string; sec: string } | null>(null);
const [copied, setCopied] = useState<"text" | "code" | null>(null);
const [message, setMessage] = useState("");
const rootRef = useRef<HTMLElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const artRef = useRef<HTMLDivElement>(null);
const timeRef = useRef<HTMLSpanElement>(null);
const maskLightRef = useRef<SVGCircleElement>(null);
const ringRef = useRef<SVGGElement>(null);
const arcRef = useRef<SVGCircleElement | null>(null);
const letterRefs = useRef<(SVGTextElement | null)[]>([]);
const maskRevealRef = useRef<SVGCircleElement>(null);
const slotRefs = useRef<(HTMLSpanElement | null)[]>([]);
const live = useRef({
target: { x: 0, y: 0 },
cur: { x: 0, y: 0 },
placed: false,
raf: 0,
last: 0,
progress: 0,
active: null as number | null,
judge: false,
start: 0,
phase: "idle" as "idle" | "playing" | "done",
found: [] as boolean[],
round: [] as Round,
size: { w: 0, h: 0 },
reduce: false,
flyerId: 0,
});
const w = size.w;
const R = w ? Math.round(Math.min(radius, Math.max(52, w * 0.15))) : radius;
const edge = Math.min(24, R * 0.3);
const letterSize = w ? Math.max(15, Math.round(w * 0.032)) : 15;
const ringBox = Math.round(letterSize * 2.4);
const arcR = ringBox / 2 - 3;
const arcLen = 2 * Math.PI * arcR;
const lanternR = (lantern.radius ?? 0.11) * w;
// 最新の値を rAF・イベントから読めるように写す(描画中には ref を読まない)
useEffect(() => {
const s = live.current;
s.found = found;
s.round = round;
s.phase = phase;
s.reduce = reduce;
}, [found, round, phase, reduce]);
const applyLight = useCallback(() => {
const { cur } = live.current;
const t = `translate(${cur.x.toFixed(1)} ${cur.y.toFixed(1)})`;
maskLightRef.current?.setAttribute("transform", t);
maskRevealRef.current?.setAttribute("transform", t);
ringRef.current?.setAttribute("transform", t);
}, []);
useEffect(() => {
const el = sceneRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const next = { w: el.offsetWidth, h: el.offsetHeight };
const s = live.current;
if (!s.placed) {
s.target = { x: (START_LIGHT.x / 100) * next.w, y: (START_LIGHT.y / 100) * next.h };
s.cur = { ...s.target };
} else if (s.size.w) {
const k = next.w / s.size.w;
s.target = { x: s.target.x * k, y: s.target.y * k };
s.cur = { x: s.cur.x * k, y: s.cur.y * k };
}
s.size = next;
setSize(next);
applyLight();
});
ro.observe(el);
return () => ro.disconnect();
}, [applyLight]);
useEffect(() => applyLight(), [applyLight, R, w, hydrated]);
// 計時(表示は 0.1 秒刻み)
useEffect(() => {
if (phase !== "playing") return;
const id = window.setInterval(() => setElapsed(performance.now() - live.current.start), 100);
return () => window.clearInterval(id);
}, [phase]);
useEffect(() => () => cancelAnimationFrame(live.current.raf), []);
useEffect(
() => () => {
if (share) URL.revokeObjectURL(share.url);
},
[share],
);
const setArc = useCallback(
(p: number) => {
if (arcRef.current) arcRef.current.style.strokeDashoffset = String(arcLen * (1 - Math.min(1, p)));
},
[arcLen],
);
const tickRef = useRef<(t: number) => void>(() => {});
const makeShare = useCallback(
async (sec: string) => {
const art = artRef.current?.querySelector("svg");
const root = rootRef.current;
if (!art || !root) return;
const clone = art.cloneNode(true) as SVGSVGElement;
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("width", String(SCENE_W));
clone.setAttribute("height", String(SCENE_H));
clone.removeAttribute("style");
const svgUrl = URL.createObjectURL(
new Blob([new XMLSerializer().serializeToString(clone)], { type: "image/svg+xml;charset=utf-8" }),
);
try {
const img = await loadImage(svgUrl);
await document.fonts?.ready;
const sans = getComputedStyle(root).fontFamily;
const display = timeRef.current ? getComputedStyle(timeRef.current).fontFamily : sans;
const cv = document.createElement("canvas");
cv.width = 1200;
cv.height = 630;
const ctx = cv.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, 1200, 630);
// 暗い場面の一部(左 700×520)
const sw = (700 / 520) * 400;
ctx.drawImage(img, 70, 200, sw, 400, 0, 0, 700, 520);
const dark = document.createElement("canvas");
dark.width = 700;
dark.height = 520;
const dctx = dark.getContext("2d");
if (dctx) {
dctx.fillStyle = "rgba(5,7,15,0.92)";
dctx.fillRect(0, 0, 700, 520);
dctx.globalCompositeOperation = "destination-out";
const hole = (x: number, y: number, r: number, a: number) => {
const g = dctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `rgba(0,0,0,${a})`);
g.addColorStop((r - 30) / r, `rgba(0,0,0,${a})`);
g.addColorStop(1, "rgba(0,0,0,0)");
dctx.fillStyle = g;
dctx.beginPath();
dctx.arc(x, y, r, 0, Math.PI * 2);
dctx.fill();
};
hole(500, 400, 150, 1);
hole(129, 107, 90, 0.85);
ctx.drawImage(dark, 0, 0);
}
ctx.strokeStyle = "rgba(217,119,6,0.55)";
ctx.lineWidth = 2;
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, 700, 520);
ctx.clip();
ctx.beginPath();
ctx.arc(500, 400, 136, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
// 右の結果
ctx.fillStyle = AMBER;
ctx.fillRect(700, 0, 2, 520);
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#57534e";
ctx.font = `500 24px ${sans}`;
ctx.fillText(`隠れた${count}文字を`, 748, 110);
ctx.fillStyle = AMBER;
ctx.font = `700 112px ${display}`;
ctx.fillText(sec, 744, 250);
const numW = ctx.measureText(sec).width;
ctx.fillStyle = "#1c1917";
ctx.font = `700 40px ${sans}`;
ctx.fillText("秒", 752 + numW, 250);
ctx.font = `700 32px ${sans}`;
ctx.fillText("で見つけた", 748, 310);
const box = Math.min(64, Math.floor((404 - (count - 1) * 10) / count));
for (let i = 0; i < count; i++) {
const x = 748 + i * (box + 10);
ctx.strokeStyle = "#d6d3d1";
ctx.lineWidth = 1;
ctx.strokeRect(x + 0.5, 370.5, box, box);
ctx.fillStyle = "#a8a29e";
ctx.font = `700 ${Math.round(box * 0.5)}px ${sans}`;
ctx.textAlign = "center";
ctx.fillText("?", x + box / 2, 370 + box * 0.68);
ctx.textAlign = "left";
}
ctx.fillStyle = "#78716c";
ctx.font = `500 20px ${sans}`;
ctx.fillText("あなたは何秒で見つけられますか", 748, 480);
// 下の帯: 作品名と URL
ctx.fillStyle = "#e7e5e4";
ctx.fillRect(0, 520, 1200, 1);
ctx.fillStyle = "#1c1917";
ctx.font = `700 28px ${sans}`;
ctx.fillText(title, 48, 566);
ctx.fillStyle = "#57534e";
ctx.font = `500 20px ${display}`;
ctx.fillText(shareUrl.replace(/^https?:\/\//, ""), 48, 600);
const blob = await new Promise<Blob | null>((r) => cv.toBlob(r, "image/png"));
if (blob) setShare({ url: URL.createObjectURL(blob), sec });
} finally {
URL.revokeObjectURL(svgUrl);
}
},
[count, shareUrl, title],
);
const finish = useCallback(() => {
const s = live.current;
const ms = performance.now() - s.start;
s.phase = "done";
s.judge = false;
setPhase("done");
setElapsed(ms);
const sec = (ms / 1000).toFixed(1);
setMessage(`${count}文字そろいました。${sec}秒でした`);
void makeShare(sec);
}, [count, makeShare]);
const capture = useCallback(
(index: number) => {
const s = live.current;
const nextFound = s.found.map((f, i) => f || i === index);
s.found = nextFound;
s.active = null;
s.progress = 0;
setFound(nextFound);
setActive(null);
const left = nextFound.filter((f) => !f).length;
setMessage(left ? `「${chars[index]}」を見つけました。残り${left}文字` : "");
const root = rootRef.current?.getBoundingClientRect();
const from = letterRefs.current[s.round[index]]?.getBoundingClientRect();
const to = slotRefs.current[index]?.getBoundingClientRect();
if (s.reduce || !root || !from || !to) {
setLanded((l) => l.map((v, i) => v || i === index));
} else {
const fx = from.left + from.width / 2 - root.left;
const fy = from.top + from.height / 2 - root.top;
const tx = to.left + to.width / 2 - root.left;
const ty = to.top + to.height / 2 - root.top;
const fs = from.height;
setFlyers((f) => [
...f,
{ id: ++s.flyerId, index, char: chars[index], x: fx, y: fy, dx: tx - fx, dy: ty - fy, scale: (to.height * 0.5) / Math.max(1, fs * 0.82), size: fs },
]);
}
if (!left) finish();
},
[chars, finish],
);
const tick = useCallback(
(t: number) => {
const s = live.current;
const dt = Math.min(500, t - s.last);
s.last = t;
const k = s.reduce ? 1 : 1 - Math.exp(-dt / 60);
s.cur.x += (s.target.x - s.cur.x) * k;
s.cur.y += (s.target.y - s.cur.y) * k;
applyLight();
let cand: number | null = null;
if (s.judge && s.phase === "playing") {
const thr = Math.max(R * 0.5, letterSize);
let best = thr;
s.round.forEach((spot, i) => {
if (s.found[i]) return;
const p = spots[spot];
const d = Math.hypot((p.x / 100) * s.size.w - s.cur.x, (p.y / 100) * s.size.h - s.cur.y);
if (d < best) {
best = d;
cand = i;
}
});
}
if (cand !== s.active) {
s.active = cand;
s.progress = 0;
setActive(cand);
setArc(0);
} else if (cand !== null) {
s.progress += dt;
setArc(s.progress / dwellMs);
if (s.progress >= dwellMs) capture(cand);
}
const moving = Math.abs(s.target.x - s.cur.x) + Math.abs(s.target.y - s.cur.y) > 0.4;
s.raf = moving || s.active !== null ? requestAnimationFrame((n) => tickRef.current(n)) : 0;
},
[R, letterSize, spots, dwellMs, capture, applyLight, setArc],
);
useEffect(() => {
tickRef.current = tick;
}, [tick]);
const kick = () => {
const s = live.current;
if (s.raf) return;
s.last = performance.now();
s.raf = requestAnimationFrame((n) => tickRef.current(n));
};
const begin = () => {
const s = live.current;
s.placed = true;
s.judge = true;
if (s.phase === "idle") {
s.phase = "playing";
s.start = performance.now();
setPhase("playing");
setMessage("探索を始めました");
}
};
const onPointer = (e: PointerEvent<HTMLDivElement>) => {
const el = sceneRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const scale = r.width / el.offsetWidth || 1;
live.current.target = { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale };
begin();
kick();
};
const release = () => {
const s = live.current;
s.judge = false;
if (s.active !== null) {
s.active = null;
s.progress = 0;
setActive(null);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const dir: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const d = dir[e.key];
if (!d) return;
e.preventDefault();
const s = live.current;
const step = Math.max(16, s.size.w * 0.035) * (e.shiftKey ? 3 : 1);
s.target = {
x: Math.min(s.size.w, Math.max(0, s.target.x + d[0] * step)),
y: Math.min(s.size.h, Math.max(0, s.target.y + d[1] * step)),
};
begin();
kick();
};
const reset = () => {
const s = live.current;
cancelAnimationFrame(s.raf);
s.raf = 0;
const next = makeRound(count, spots.length, lantern.spot);
const empty = chars.map(() => false);
Object.assign(s, {
round: next,
found: empty,
phase: "idle",
judge: false,
active: null,
progress: 0,
placed: false,
target: { x: (START_LIGHT.x / 100) * s.size.w, y: (START_LIGHT.y / 100) * s.size.h },
});
s.cur = { ...s.target };
applyLight();
setRound(next);
setFound(empty);
setLanded(empty);
setFlyers([]);
setPhase("idle");
setElapsed(0);
setActive(null);
setShare(null);
setCopied(null);
setMessage("新しい配置で始めます。文字の場所は前回と変わります");
sceneRef.current?.focus({ preventScroll: true });
};
const flyerRef = (f: Flyer) => (el: HTMLSpanElement | null) => {
if (!el || el.dataset.go) return;
el.dataset.go = "1";
const anim = el.animate(
[
{ transform: "translate(0px, 0px) scale(1)", opacity: 1 },
{ transform: `translate(${f.dx}px, ${f.dy - 24}px) scale(${(1 + f.scale) / 1.6})`, opacity: 1, offset: 0.55 },
{ transform: `translate(${f.dx}px, ${f.dy}px) scale(${f.scale})`, opacity: 1 },
],
{ duration: 560, easing: "cubic-bezier(.2,.7,.2,1)", fill: "forwards" },
);
anim.onfinish = () => {
setLanded((l) => l.map((v, i) => v || i === f.index));
setFlyers((list) => list.filter((x) => x.id !== f.id));
};
};
const sec = (elapsed / 1000).toFixed(1);
const foundCount = found.filter(Boolean).length;
const shareText = `隠れた${count}文字を${share?.sec ?? sec}秒で見つけた。あなたは? ${shareUrl}`;
const doCopy = async (kind: "text" | "code") => {
const ok = await copyText(kind === "text" ? shareText : PROPS_EXAMPLE);
setCopied(ok ? kind : null);
if (ok) setMessage(kind === "text" ? "コピー文をコピーしました" : "コードをコピーしました");
};
const btn =
"inline-flex min-h-11 items-center justify-center border px-4 text-sm font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] outline-none";
return (
<section
ref={rootRef}
className="relative mx-auto w-full max-w-6xl border border-stone-200 bg-white text-stone-900"
aria-labelledby={`${uid}-title`}
>
{/* 見出しと状況 */}
<div className="grid gap-5 px-5 py-6 sm:px-8 sm:py-7 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-10">
<div className="max-w-2xl">
<p className="font-display text-xs font-semibold tracking-[0.18em] text-stone-500">
BLK-105<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="mt-3 text-[clamp(1.35rem,3.2vw,2rem)] font-bold leading-[1.4] tracking-tight text-balance [word-break:auto-phrase]">
夜の駄菓子屋に隠れた{count}文字を、懐中電灯で探す
</Heading>
</div>
<div className="flex flex-col gap-3 lg:items-end">
<ol className="flex gap-2" aria-label={`見つけた文字 ${foundCount}/${count}`}>
{chars.map((c, i) => (
<li key={i}>
<span
ref={(el) => {
slotRefs.current[i] = el;
}}
className={`flex h-11 w-11 items-center justify-center border text-xl font-bold sm:h-12 sm:w-12 sm:text-2xl ${
found[i] ? "border-[#d97706] bg-amber-50 text-stone-900" : "border-stone-300 bg-white text-stone-300"
}`}
>
{found[i] && landed[i] ? c : <span aria-hidden="true" className="font-display text-xs">{i + 1}</span>}
<span className="sr-only">{found[i] ? c : "未発見"}</span>
</span>
</li>
))}
</ol>
<div className="flex min-h-11 items-center gap-4">
<p className="text-sm text-stone-500">
経過
<span ref={timeRef} className="ml-2 font-display text-2xl font-bold tabular-nums text-stone-900">
{sec}
</span>
<span className="ml-0.5 text-sm text-stone-900">秒</span>
</p>
{phase === "done" ? (
<button type="button" onClick={reset} className={`${btn} restart border-[#d97706] bg-[#d97706] text-white hover:bg-amber-700`}>
もう一度
</button>
) : (
<p className="text-sm text-stone-500">
{phase === "idle" ? "なぞると計時が始まります" : `残り${count - foundCount}文字`}
</p>
)}
</div>
</div>
</div>
{/* 場面 */}
<div
ref={sceneRef}
tabIndex={0}
role="group"
aria-roledescription="探索の場面"
aria-label={`夜の駄菓子屋の場面。矢印キーで懐中電灯を動かします。Shift と一緒に押すと大きく動きます`}
onPointerMove={onPointer}
onPointerDown={onPointer}
onPointerLeave={release}
onPointerCancel={release}
onKeyDown={onKeyDown}
className="relative aspect-[3/2] w-full cursor-crosshair touch-none select-none overflow-hidden border-y border-stone-200 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#d97706]"
style={{ background: NAVY }}
>
<div ref={artRef} className="absolute inset-0" aria-hidden="true">
{scene ?? <DagashiNightScene />}
</div>
{/* 暗闇(明るさ 8%)と光の輪 */}
<svg
className={`pointer-events-none absolute inset-0 z-20 h-full w-full transition-opacity duration-700 motion-reduce:transition-none ${
phase === "done" ? "opacity-30" : "opacity-100"
}`}
aria-hidden="true"
>
<defs>
<radialGradient id={`${uid}-hole`}>
<stop offset="0" stopColor="#000" stopOpacity="1" />
<stop offset={(R - edge) / R} stopColor="#000" stopOpacity="1" />
<stop offset="1" stopColor="#000" stopOpacity="0" />
</radialGradient>
<radialGradient id={`${uid}-lamp`}>
<stop offset="0" stopColor="#000" stopOpacity="0.92" />
<stop offset="0.6" stopColor="#000" stopOpacity="0.8" />
<stop offset="1" stopColor="#000" stopOpacity="0" />
</radialGradient>
<radialGradient id={`${uid}-warm`}>
<stop offset="0" stopColor={ORANGE} stopOpacity="0.32" />
<stop offset="1" stopColor={ORANGE} stopOpacity="0" />
</radialGradient>
<mask id={`${uid}-mask`} maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
<rect width="100%" height="100%" fill="#fff" />
{w > 0 && (
<circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR} fill={`url(#${uid}-lamp)`} />
)}
<circle ref={maskLightRef} r={R} fill={`url(#${uid}-hole)`} />
</mask>
</defs>
<rect width="100%" height="100%" fill={DARK} fillOpacity="0.92" mask={`url(#${uid}-mask)`} />
{w > 0 && <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR * 1.1} fill={`url(#${uid}-warm)`} />}
</svg>
{/* 隠し文字は暗闇の上に置き、光の届く範囲だけ mask で見せる(暗闇の下に置くと 8% の明るさでも輪郭が読めてしまう) */}
{hydrated && w > 0 && (
<svg className="pointer-events-none absolute inset-0 z-[25] h-full w-full" aria-hidden="true">
<defs>
<radialGradient id={`${uid}-see`}>
<stop offset="0" stopColor="#fff" stopOpacity="1" />
<stop offset={(R - edge) / R} stopColor="#fff" stopOpacity="1" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</radialGradient>
<radialGradient id={`${uid}-seelamp`}>
<stop offset="0" stopColor="#fff" stopOpacity="0.9" />
<stop offset="0.6" stopColor="#fff" stopOpacity="0.75" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</radialGradient>
<mask id={`${uid}-reveal`} maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
<rect width="100%" height="100%" fill="#000" />
<circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR} fill={`url(#${uid}-seelamp)`} />
<circle ref={maskRevealRef} r={R} fill={`url(#${uid}-see)`} />
</mask>
</defs>
{round.map((spot, i) => {
const p = spots[spot];
const on = active === i;
const x = (p.x / 100) * w;
const y = (p.y / 100) * size.h;
const shown = found[i] || phase === "done";
return (
<g key={`${spot}-${i}`} mask={shown ? undefined : `url(#${uid}-reveal)`}>
<text
ref={(el) => {
letterRefs.current[spot] = el;
}}
x={x}
y={y}
transform={`rotate(${p.rotate ?? 0} ${x} ${y})`}
textAnchor="middle"
dominantBaseline="central"
fontSize={letterSize}
fontWeight={900}
fill={shown || on ? "#fbbf24" : CREAM}
fillOpacity={found[i] && phase !== "done" ? 0.7 : 1}
stroke={on ? AMBER : "none"}
strokeWidth={on ? 2 : 0}
paintOrder="stroke"
>
{chars[i]}
</text>
</g>
);
})}
</svg>
)}
<svg className="pointer-events-none absolute inset-0 z-30 h-full w-full overflow-visible" aria-hidden="true">
<g ref={ringRef} className={phase === "done" ? "opacity-0" : "opacity-100"}>
<circle r={R - edge / 2} fill="none" stroke={AMBER} strokeOpacity="0.45" strokeWidth="1.5" />
<circle r={3} fill={AMBER} fillOpacity="0.8" />
</g>
</svg>
{active !== null && (
<div
className="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2"
style={{ left: `${spots[round[active]].x}%`, top: `${spots[round[active]].y}%`, width: ringBox, height: ringBox }}
aria-hidden="true"
>
<svg width={ringBox} height={ringBox} className="-rotate-90">
<circle cx={ringBox / 2} cy={ringBox / 2} r={arcR} fill="none" stroke={CREAM} strokeOpacity="0.25" strokeWidth="2" />
<circle
ref={(el) => {
arcRef.current = el;
if (el) el.style.strokeDashoffset = String(arcLen);
}}
cx={ringBox / 2}
cy={ringBox / 2}
r={arcR}
fill="none"
stroke="#f59e0b"
strokeWidth="3"
strokeDasharray={arcLen}
/>
</svg>
</div>
)}
{/* 自動テスト・案内用の候補位置の目印(見えない) */}
{spots.map((p, i) => (
<span
key={i}
data-spot={i}
aria-hidden="true"
className="absolute z-40 h-2 w-2 -translate-x-1/2 -translate-y-1/2 opacity-0"
style={{ left: `${p.x}%`, top: `${p.y}%` }}
/>
))}
{phase === "idle" && (
<p className="pointer-events-none absolute left-3 top-3 z-30 max-w-[62%] bg-[#05070f]/80 px-3 py-2 text-xs leading-relaxed text-[#efe6d2] sm:left-5 sm:top-5 sm:text-sm">
なぞると懐中電灯が照らします。まずは軒下の灯りの横から
</p>
)}
</div>
{flyers.map((f) => (
<span
key={f.id}
ref={flyerRef(f)}
aria-hidden="true"
className="pointer-events-none absolute z-50 flex items-center justify-center font-black leading-none text-[#d97706]"
style={{ left: f.x - f.size, top: f.y - f.size, width: f.size * 2, height: f.size * 2, fontSize: f.size * 0.82 }}
>
{f.char}
</span>
))}
<div className="grid gap-2 px-5 pt-5 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
<p className="text-[15px] leading-[1.85] text-stone-600">
店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。場面の上をなぞると光の輪がついてきて、文字の上で少し止まると上のマスに入ります。文字の場所は毎回変わります。
</p>
<p className="text-xs leading-relaxed text-stone-500 lg:pt-1">
マウス・指: 場面の上をなぞる(指を離すと判定が外れます)/キーボード: 場面を選んで矢印キー、Shift で大きく動かす
</p>
</div>
<p className="sr-only" aria-live="polite">
{message}
</p>
{/* 結果と共有 */}
<div className="mt-6 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>
{phase === "done" ? (
<>
<p className="mt-3 text-sm leading-relaxed text-stone-600">
{count}文字そろうまで
<span className="mx-1 font-display text-3xl font-bold tabular-nums text-[#d97706]">{sec}</span>秒でした。共有の画像では合言葉を伏せています。
</p>
<label className="mt-4 block text-xs font-bold text-stone-500" htmlFor={`${uid}-text`}>
コピー文
</label>
<textarea
id={`${uid}-text`}
readOnly
value={shareText}
rows={3}
className="mt-1 w-full resize-none border border-stone-300 bg-stone-50 p-3 text-sm leading-relaxed text-stone-800 outline-none focus-visible:outline-2 focus-visible:outline-[#d97706]"
/>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={() => doCopy("text")} className={`${btn} copy-text border-stone-900 bg-stone-900 text-white hover:bg-stone-700`}>
{copied === "text" ? "コピーしました" : "コピー文をコピー"}
</button>
{share && (
<a href={share.url} download={`flashlight-${share.sec}s.png`} className={`${btn} border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
画像を保存
</a>
)}
<button type="button" onClick={reset} className={`${btn} border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
もう一度
</button>
</div>
</>
) : (
<p className="mt-3 text-sm leading-relaxed text-stone-600">
{count}文字そろうと、秒数と共有用の画像(1200×630)がここに出ます。画像は端末の中で作り、どこにも送信しません。
</p>
)}
</div>
<div className="self-start border border-stone-200 bg-stone-50">
{share ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={share.url} alt={`共有用の画像。隠れた${count}文字を${share.sec}秒で見つけた結果`} width={1200} height={630} className="block h-auto w-full" />
) : (
<div className="flex aspect-[1200/630] items-center justify-center px-6 text-center text-sm text-stone-400">
共有用の画像(暗い場面の一部・秒数・作品名・URL)
</div>
)}
</div>
</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">場面の SVG・文字・候補位置を渡せば、同じ仕組みで別の季節に使えます。</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">year-end-campaign.tsx</span>
<button type="button" onClick={() => doCopy("code")} className={`${btn} min-h-9 border-stone-300 bg-white px-3 text-xs text-stone-900 hover:border-[#d97706]`}>
{copied === "code" ? "コピーしました" : "コードをコピー"}
</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>{PROPS_EXAMPLE}</code>
</pre>
</div>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/halloween-flashlight-reveal.json