Slap Board (tags stay where you click)
An interactive block that turns nine service tags into paper stickers you slap onto a board wherever you click. Each tag lands by unrolling from a curled state and then stays put, while the tally on the right builds up counts and hairline bars, so the trace of play becomes a usable brief. The peel needs no images and no 3D: the face is clipped to a half-plane and a mirrored back-of-paper gradient is layered on top through the same fold, with a single custom property driving the fold from 0 to 1. All geometry is expressed as a fraction of the element's own edge, so the sticker size can stay fluid via clamp without touching the JavaScript. Once flat, a sticker drops both the clip-path and the mirror layer and falls back to a single cheap face, so piling up 24 of them still only costs the few that are mid-unroll. Positions are stored as percentages of the board; rotation uses the rotate property and centring uses translate, leaving transform free for the entrance pop. Under prefers-reduced-motion nothing unrolls or pops — the tag simply appears where you pressed, so the interaction never goes dead. The board is aria-hidden and the same information is carried by the tally list plus a role=status announcement; keyboard users get golden-ratio scattered positions. No dependencies, no image assets. Only the idea of leaving a trace where you click is borrowed from an external work; all code, copy, colour and composition are original.
- Added:
- 2026-09-18
- Dependencies:
- None
- tags
- #block #interactive #pointer #clip-path #stateful #tally #motion #no-image #no-dependency
Preview
BLK-67標本 / 相談メモ・貼り板
気になるものを、板に貼ってください
お引き受けできることを9枚のふせんにしました。ふせんを選んで板の好きな位置を押すと、その場所に貼られます。貼った分は右の控えに数として残るので、消さずにそのままご相談の内容にできます。
- コーポレート1
- 採用ページ0
- LP・特設0
- 予約フォーム1
- 業務システム0
- データ連携0
- 保守・運用1
- 記事の更新0
- AI活用0
貼った内容はこの画面の中だけで、送信も保存もされません。上限は24枚で、超えた分は古いものから外れます。
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 相談メモ・貼り板(BLK-67)
*
* 差別化メモ: 既存の操作系ブロック(cursor-string-canvas)は Canvas に描いた軌跡が時間で薄れて消える
* =痕跡が残らない描画で、操作パネルは物理パラメータのスライダーだった。本ブロックは逆で、クリックした
* 位置に「紙のふせん」がDOMとして残り続け、貼るたびに右の控えで件数と内訳が数字に変わる(=痕跡が
* 集計される)。飽和している型のうち、英字キッカー・末尾の「→」リンク・スクロール出現アニメ・角丸の
* 4つを意図的に外し、動きは利用者のクリックにだけ反応する。
*
* 汎用技法メモ(web-design-playbook 還流用):
*
* 1. 「めくれた紙が開いて貼り付く」は clip-path の半平面+鏡像レイヤーの2枚で作れる
* 紙を角度θの折り目で2つに割り、①表面を「折り目の手前側」だけに clip し、②その上に
* 「紙の裏面(グラデーション)を折り目で鏡像反転させたもの」を同じ clip で重ねる。
* 折り目の位置 --p を 0→1 へ動かすと、裏面が減って表面が現れる=紙が開く。画像も3Dも要らない。
* 半平面の clip-path は、折り目上の点 P=fold*u と法線 u・接線 w から
* polygon(P+Bw, P-Bw, P-Bw-Bu, P+Bw-Bu) ※B は要素より十分大きい定数
* で書ける(辺を4点で囲むのではなく「十分大きい帯」で塗り潰す発想)。
*
* 2. 鏡映変換は matrix() を組まずに translate→rotate→scaleY(-1)→rotate→translate で書く
* matrix() の各引数に calc() を入れる書き方は環境差が出るうえ読めない。折り目の点へ原点を運び、
* 折り目の向きへ回し、Y反転し、戻す——の5段で同じ鏡映になる。transform-origin: 0 0 が条件。
*
* 3. 幾何をすべて「自分の辺長に対する割合」で持つと、要素サイズをCSS側で自由に決められる
* clip-path の座標も translate() の量も % が使えるので、単位正方形(1辺=1)で計算した値に
* 100% を掛けるだけでよい。結果、ふせんの大きさを clamp(68px, 17vw相当, 126px) のように
* 可変にしてもJS側の計算が一切変わらない(狭幅で小さく、広幅で大きくできる)。
*
* 4. めくれ機構は「開き終わったら捨てる」
* 開き切った紙は clip-path も鏡像レイヤーも要らない。settled になった時点で表面1枚だけの
* 軽い状態に置き換える。貼った枚数が増えても、毎フレームの負荷は"開いている最中の数枚"にしか
* 掛からない(常時24枚分の clip-path を再計算させない)。
*
* 5. 位置は px でなく板に対する % で持つ
* 貼った後に板の幅が変われば、px 座標は板の外へ出てしまう。中心座標を % で持ち
* `translate: -50% -50%` で中央合わせすれば、リサイズしても相対位置が保たれる。
* 回転は rotate プロパティ、寄せは translate プロパティに分けて持たせると、transform が
* 空くので出現時の scale アニメーション(WAAPI)と喧嘩しない。
*
* 6. 盤面は読み上げ対象にしない
* 散らばった紙のDOMを読み上げても順序に意味がない。盤面は aria-hidden にし、同じ情報を
* 「控え」のリストで持たせ、貼った瞬間だけ role="status" で1行知らせる。
* キーボード操作では座標が無いので、黄金比で散らした既定位置へ落とす。
*/
type Tone = "paper" | "amber" | "ink";
type Item = { id: string; label: string; group: string; tone: Tone };
const GROUPS: { name: string; tone: Tone; items: { id: string; label: string }[] }[] = [
{
name: "つくる",
tone: "paper",
items: [
{ id: "corp", label: "コーポレート" },
{ id: "recruit", label: "採用ページ" },
{ id: "lp", label: "LP・特設" },
],
},
{
name: "つなぐ",
tone: "amber",
items: [
{ id: "form", label: "予約フォーム" },
{ id: "system", label: "業務システム" },
{ id: "link", label: "データ連携" },
],
},
{
name: "つづける",
tone: "ink",
items: [
{ id: "ops", label: "保守・運用" },
{ id: "post", label: "記事の更新" },
{ id: "ai", label: "AI活用" },
],
},
];
const ITEMS: Item[] = GROUPS.flatMap((g) =>
g.items.map((it) => ({ id: it.id, label: it.label, group: g.name, tone: g.tone })),
);
const BY_ID = new Map(ITEMS.map((it) => [it.id, it]));
const TONE_FACE: Record<Tone, string> = {
paper: "border-gray-300 bg-white text-gray-900",
amber: "border-amber-700 bg-amber-600 text-white",
ink: "border-gray-900 bg-gray-900 text-white",
};
const TONE_CHIP: Record<Tone, string> = {
paper: "border-gray-900 bg-gray-900 text-white",
amber: "border-amber-600 bg-amber-600 text-white",
ink: "border-gray-900 bg-gray-900 text-white",
};
type Slap = {
id: number;
item: string;
/** 板に対する中心座標(%) */
x: number;
y: number;
/** 静止時の傾き(度) */
rot: number;
/** 折り目の向き(度) */
ang: number;
/** 開き終わって、めくれ機構を捨てた状態 */
settled: boolean;
};
const LIMIT = 24;
const PEEL_MS = 720;
/** 貼られた瞬間の開き具合(0=完全に丸まっている / 1=平ら) */
const START_P = 0.12;
/** ふせんの一辺。CSS側の clamp と同じ値をJS側の内側判定にも使う */
const SIZE_CSS = "clamp(4.25rem, 17%, 7.875rem)";
const SIZE_MIN = 68;
const SIZE_MAX = 126;
const SIZE_RATIO = 0.17;
/** 初期状態(SSRとクライアントで同じ値になるよう乱数を使わず直書きする) */
const SEEDED: Slap[] = [
{ id: -3, item: "corp", x: 27, y: 33, rot: -7, ang: 34, settled: true },
{ id: -2, item: "form", x: 55, y: 62, rot: 5, ang: 128, settled: true },
{ id: -1, item: "ops", x: 76, y: 29, rot: -3, ang: 214, settled: true },
];
/**
* 折り目の幾何。単位正方形(1辺=1)の4隅を折り目の法線 u へ射影し、
* 折り目が動く範囲 [min, min+span] を出す。すべて辺長に対する割合なので px に依存しない。
*/
function foldGeometry(angleDeg: number) {
const a = (angleDeg * Math.PI) / 180;
const u0 = Math.cos(a);
const u1 = Math.sin(a);
const projections = [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
].map(([x, y]) => x * u0 + y * u1);
const min = Math.min(...projections);
const span = Math.max(...projections) - min;
return { u0, u1, w0: -u1, w1: u0, min, span };
}
/** 折り目より手前(=もう貼り付いた側)だけを残す半平面 */
const HALF_PLANE = [
"polygon(",
"calc((var(--fold) * var(--u0) + var(--big) * var(--w0)) * 100%)",
"calc((var(--fold) * var(--u1) + var(--big) * var(--w1)) * 100%),",
"calc((var(--fold) * var(--u0) - var(--big) * var(--w0)) * 100%)",
"calc((var(--fold) * var(--u1) - var(--big) * var(--w1)) * 100%),",
"calc((var(--fold) * var(--u0) - var(--big) * var(--w0) - var(--big) * var(--u0)) * 100%)",
"calc((var(--fold) * var(--u1) - var(--big) * var(--w1) - var(--big) * var(--u1)) * 100%),",
"calc((var(--fold) * var(--u0) + var(--big) * var(--w0) - var(--big) * var(--u0)) * 100%)",
"calc((var(--fold) * var(--u1) + var(--big) * var(--w1) - var(--big) * var(--u1)) * 100%)",
")",
].join(" ");
/** 折り目を軸にした鏡映(matrix を組まず5段の変換で書く) */
const REFLECT = [
"translate(calc(var(--fold) * var(--u0) * 100%), calc(var(--fold) * var(--u1) * 100%))",
"rotate(calc(var(--ca) * 1deg))",
"scaleY(-1)",
"rotate(calc(var(--ca) * -1deg))",
"translate(calc(var(--fold) * var(--u0) * -1 * 100%), calc(var(--fold) * var(--u1) * -1 * 100%))",
].join(" ");
/** めくれ上がった紙が落とす影。開くほど薄く、近くなる */
const FLAP_SHADOW =
"drop-shadow(0 calc((1 - var(--p)) * 0.45rem) calc((1 - var(--p)) * 0.55rem) rgb(28 25 23 / calc(0.3 * (1 - var(--p)))))";
const FLAP_BACK = "linear-gradient(calc(var(--ca) * 1deg), #f8f5f1, #d8d1c7)";
export default function TagSlapBoard() {
const [slaps, setSlaps] = useState<Slap[]>(SEEDED);
const [picked, setPicked] = useState("lp");
const [notice, setNotice] = useState("");
const boardRef = useRef<HTMLDivElement>(null);
const seqRef = useRef(1);
const keySeqRef = useRef(0);
const activeRef = useRef(new Map<number, number>());
const reducedRef = useRef(false);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
reducedRef.current = mql.matches;
};
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
// 貼られた直後のふせんを登録し、開いている最中のものだけを毎フレーム進める。
// slaps が変わるたびにループを張り直すが、経過時間は activeRef が持っているので進行は途切れない。
useEffect(() => {
const board = boardRef.current;
if (!board) return;
for (const s of slaps) {
if (s.settled || activeRef.current.has(s.id)) continue;
const el = board.querySelector<HTMLElement>(`[data-slap="${s.id}"]`);
if (!el) continue;
activeRef.current.set(s.id, performance.now());
el.animate(
[
{ opacity: 0, transform: "scale(1.08)" },
{ opacity: 1, transform: "scale(1)" },
],
{ duration: 220, easing: "cubic-bezier(0.22, 0.8, 0.3, 1)" },
);
}
if (activeRef.current.size === 0) return;
let raf = 0;
const loop = (now: number) => {
const finished: number[] = [];
activeRef.current.forEach((start, id) => {
const el = board.querySelector<HTMLElement>(`[data-slap="${id}"]`);
if (!el) {
activeRef.current.delete(id);
return;
}
const t = Math.min(1, (now - start) / PEEL_MS);
const eased = 1 - Math.pow(1 - t, 3);
el.style.setProperty("--p", (START_P + (1 - START_P) * eased).toFixed(4));
if (t >= 1) finished.push(id);
});
finished.forEach((id) => activeRef.current.delete(id));
if (finished.length > 0) {
setSlaps((prev) =>
prev.map((s) => (finished.includes(s.id) ? { ...s, settled: true } : s)),
);
return;
}
if (activeRef.current.size > 0) raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [slaps]);
const place = useCallback(
(clientX: number | null, clientY: number | null) => {
const board = boardRef.current;
if (!board) return;
const rect = board.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const size = Math.min(SIZE_MAX, Math.max(SIZE_MIN, rect.width * SIZE_RATIO));
// 傾くぶん外接する箱は少し大きい。1.12 を掛けて端でもはみ出させない
const marginX = ((size * 1.12) / 2 / rect.width) * 100;
const marginY = ((size * 1.12) / 2 / rect.height) * 100;
let x: number;
let y: number;
if (clientX === null || clientY === null) {
// キーボード操作には座標が無いので、黄金比で散らした既定位置へ落とす
const k = keySeqRef.current++;
x = 14 + ((k * 61.803) % 72);
y = 16 + ((k * 38.197 + 21) % 68);
} else {
x = ((clientX - rect.left) / rect.width) * 100;
y = ((clientY - rect.top) / rect.height) * 100;
}
const item = BY_ID.get(picked);
if (!item) return;
const slap: Slap = {
id: seqRef.current++,
item: picked,
x: Math.min(100 - marginX, Math.max(marginX, x)),
y: Math.min(100 - marginY, Math.max(marginY, y)),
rot: -11 + Math.random() * 22,
ang: Math.random() * 360,
settled: reducedRef.current,
};
setSlaps((prev) => {
const next = [...prev, slap];
return next.length > LIMIT ? next.slice(next.length - LIMIT) : next;
});
setNotice(`${item.label}を貼りました`);
},
[picked],
);
const counts = useMemo(() => {
const map = new Map<string, number>();
for (const s of slaps) map.set(s.item, (map.get(s.item) ?? 0) + 1);
return map;
}, [slaps]);
const peak = useMemo(() => Math.max(1, ...Array.from(counts.values())), [counts]);
const pickedItem = BY_ID.get(picked);
return (
<section
aria-labelledby="tag-slap-board-heading"
className="@container w-full max-w-4xl bg-white px-1"
>
<header className="max-w-[42rem]">
<p className="flex items-center gap-2.5 text-[11px] text-gray-400">
<span aria-hidden="true" className="h-px w-5 bg-amber-600" />
<span className={`${montserrat.className} font-semibold text-gray-500`}>BLK-67</span>
<span>標本 / 相談メモ・貼り板</span>
</p>
<h2
id="tag-slap-board-heading"
className="mt-4 text-[clamp(1.55rem,3.4vw,2.3rem)] leading-[1.35] font-bold tracking-tight text-balance text-gray-900"
>
気になるものを、板に貼ってください
</h2>
<p className="mt-5 text-[14.5px] leading-[1.95] font-medium text-gray-600">
お引き受けできることを9枚のふせんにしました。ふせんを選んで板の好きな位置を押すと、その場所に貼られます。貼った分は右の控えに数として残るので、消さずにそのままご相談の内容にできます。
</p>
</header>
{/* ふせん立て: 3つの束を hairline で仕切り、束の名前を添える */}
<div className="mt-9 flex flex-col gap-3 border-t border-gray-200 pt-5 @2xl:flex-row @2xl:gap-0">
{GROUPS.map((group, gi) => (
<div
key={group.name}
className={`flex flex-wrap items-center gap-x-3 gap-y-2 @2xl:flex-1 ${
gi === 0 ? "" : "border-t border-gray-200 pt-3 @2xl:border-t-0 @2xl:border-l @2xl:pt-0 @2xl:pl-5"
} ${gi === GROUPS.length - 1 ? "" : "@2xl:pr-5"}`}
>
<span className="w-full text-[11px] leading-none font-bold tracking-[0.06em] text-gray-400">
{group.name}
</span>
{group.items.map((it) => {
const on = picked === it.id;
return (
<button
key={it.id}
type="button"
aria-pressed={on}
onClick={() => setPicked(it.id)}
className={`border px-2.5 py-1.5 text-[12px] leading-none font-bold tracking-tight transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
on
? TONE_CHIP[group.tone]
: "border-gray-200 bg-white text-gray-600 hover:border-gray-400 hover:text-gray-900"
}`}
>
{it.label}
</button>
);
})}
</div>
))}
</div>
<div className="mt-5 flex flex-col gap-0 border border-gray-200 @2xl:flex-row">
{/* 板 */}
<div ref={boardRef} className="relative min-h-[17rem] flex-1 overflow-hidden @md:min-h-[21rem]">
{slaps.length === 0 && (
<p className="pointer-events-none absolute inset-0 flex items-center justify-center text-[13px] font-medium text-gray-300">
板が空になりました
</p>
)}
<div aria-hidden="true" className="absolute inset-0 select-none">
{slaps.map((s) => {
const item = BY_ID.get(s.item);
if (!item) return null;
const g = foldGeometry(s.ang);
const base: Record<string, string | number> = {
left: `${s.x}%`,
top: `${s.y}%`,
width: SIZE_CSS,
aspectRatio: "1",
translate: "-50% -50%",
rotate: `${s.rot.toFixed(2)}deg`,
};
if (!s.settled) {
Object.assign(base, {
"--u0": g.u0.toFixed(5),
"--u1": g.u1.toFixed(5),
"--w0": g.w0.toFixed(5),
"--w1": g.w1.toFixed(5),
"--min": g.min.toFixed(5),
"--span": g.span.toFixed(5),
"--ca": (s.ang + 90).toFixed(2),
"--big": 6,
"--p": START_P,
"--fold": "calc(var(--min) + var(--span) * var(--p))",
});
}
return (
<div
key={s.id}
data-slap={s.id}
className="pointer-events-none absolute"
style={base as CSSProperties}
>
<div
className={`absolute inset-0 flex flex-col justify-between border p-2 ${TONE_FACE[item.tone]} ${
s.settled ? "shadow-[0_1px_2px_rgb(28_25_23/0.14)]" : ""
}`}
style={s.settled ? undefined : { clipPath: HALF_PLANE }}
>
<span className="text-[8.5px] leading-none font-bold tracking-[0.08em] opacity-65">
{item.group}
</span>
<span className="text-[10.5px] leading-[1.4] font-bold tracking-tight break-words">
{item.label}
</span>
</div>
{!s.settled && (
<div
className="absolute inset-0"
style={{ clipPath: HALF_PLANE, filter: FLAP_SHADOW }}
>
<div
className="absolute inset-0"
style={{ transformOrigin: "0 0", transform: REFLECT }}
>
<div
className="absolute inset-0 border border-gray-300"
style={{ backgroundImage: FLAP_BACK }}
/>
</div>
</div>
)}
</div>
);
})}
</div>
<p
aria-hidden="true"
className="pointer-events-none absolute bottom-3 left-3 text-[10.5px] leading-none font-medium text-gray-400"
>
<span className="[@media(pointer:coarse)]:hidden">クリックした位置に貼られます</span>
<span className="hidden [@media(pointer:coarse)]:inline">タップした位置に貼られます</span>
</p>
{/* 板全面が貼り付けボタン。ポインタは押した瞬間、キーボードは click(detail=0)で拾う */}
<button
type="button"
aria-label={`板に「${pickedItem?.label ?? ""}」を貼る`}
onPointerDown={(e) => place(e.clientX, e.clientY)}
onClick={(e) => {
if (e.detail === 0) place(null, null);
}}
className="absolute inset-0 z-10 cursor-crosshair bg-transparent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600"
/>
</div>
{/* 控え */}
<div className="shrink-0 border-t border-gray-200 @2xl:w-[13.5rem] @2xl:border-t-0 @2xl:border-l">
<div className="flex items-baseline justify-between gap-3 border-b border-gray-200 px-4 py-3.5">
<span className="text-[11px] font-bold tracking-[0.06em] text-gray-500">貼った控え</span>
<span className={`${montserrat.className} flex items-baseline gap-1 text-gray-900`}>
<span className="text-[1.6rem] leading-none font-semibold tabular-nums">
{slaps.length}
</span>
<span className="text-[10px] font-semibold text-gray-400">/ {LIMIT}</span>
</span>
</div>
<ul className="px-4 py-1">
{ITEMS.map((item, i) => {
const n = counts.get(item.id) ?? 0;
const prev = ITEMS[i - 1];
return (
<li
key={item.id}
className={`py-2 ${prev && prev.group !== item.group ? "mt-1 border-t border-gray-200 pt-3" : ""}`}
>
<span className="flex items-baseline justify-between gap-2">
<span
className={`text-[12px] leading-none font-bold tracking-tight ${n > 0 ? "text-gray-900" : "text-gray-300"}`}
>
{item.label}
</span>
<span
className={`${montserrat.className} text-[12px] leading-none font-semibold tabular-nums ${n > 0 ? "text-amber-700" : "text-gray-300"}`}
>
{n}
</span>
</span>
<span aria-hidden="true" className="mt-2 block h-px bg-gray-100">
{/* 伸縮は width ではなく scaleX(レイアウトを起こさない) */}
<span
className="block h-px origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none"
style={{ transform: `scaleX(${(n / peak).toFixed(3)})` }}
/>
</span>
</li>
);
})}
</ul>
</div>
</div>
<p role="status" aria-live="polite" className="sr-only">
{notice ? `${notice}。合計${slaps.length}枚` : ""}
</p>
<div className="mt-5 flex flex-col gap-4 border-t border-gray-200 pt-5 @md:flex-row @md:items-center @md:justify-between">
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
disabled={slaps.length === 0}
onClick={() => {
setSlaps((prev) => prev.slice(0, -1));
setNotice("1枚はがしました");
}}
className="border border-gray-300 px-3.5 py-2 text-[12px] leading-none font-bold tracking-tight whitespace-nowrap text-gray-700 transition-colors duration-200 hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:border-gray-200 disabled:text-gray-300 motion-reduce:transition-none"
>
1枚はがす
</button>
<button
type="button"
disabled={slaps.length === 0}
onClick={() => {
setSlaps([]);
setNotice("すべてはがしました");
}}
className="border border-gray-300 px-3.5 py-2 text-[12px] leading-none font-bold tracking-tight whitespace-nowrap text-gray-700 transition-colors duration-200 hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:border-gray-200 disabled:text-gray-300 motion-reduce:transition-none"
>
全部はがす
</button>
</div>
<p className="text-[12.5px] leading-[1.9] text-gray-500">
貼った内容はこの画面の中だけで、送信も保存もされません。上限は{LIMIT}枚で、超えた分は古いものから外れます。
</p>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/tag-slap-board.json