First CH Designs
BLK-67Blocks

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枚のふせんにしました。ふせんを選んで板の好きな位置を押すと、その場所に貼られます。貼った分は右の控えに数として残るので、消さずにそのままご相談の内容にできます。

つくる
つなぐ
つづける
貼った控え3/ 24
  • コーポレート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