First CH Designs
BTN-02Elements

押し込み3Dボタンシステム

スクワークル形状に厚みを持たせ、押すと面だけが沈む触覚的なボタン群。トーン3種・サイズ3種・アイコン付き/アイコンのみ・トグル・無効・ステッパーまでを1つの規則(深さ/沈み込み=深さ−2px)で通した、フラットなスタンダードボタンの対になる系。動かすのは transform のみで、押下・トグルON・無効はすべて同じ「沈んだ面」で表す。

追加日:
2026-08-15
依存:
なし
tags
#button #interactive #3d #tactile #squircle #toggle #variants

プレビュー

Press Depth

押し込みで優先度を伝えるボタン群

厚み・面・沈み込みの3値だけで階層とフィードバックを設計する。動かすのは transform のみ。

01

トーン

厚みは共通・面の色で分ける

02

スケール

厚み 4 / 6 / 8px に対応

03

アイコン

正方形・円形も同じ規則

04

状態

トグルONも無効も沈んだ面

05

連打

境界では無効の面へ落ちる

2

"use client";

import { useState } from "react";

/**
 * 押し込み3Dボタンシステム(BTN-02)
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 *
 * 1. 厚みは box-shadow ではなく「2層 + padding-bottom」で作る
 *    立体ボタンは `box-shadow: 0 6px 0 <暗色>` で作るのが手軽だが、押し込みを box-shadow の
 *    アニメーションで表現するとコンポジットに乗らず(paint が走る)、motion の規律にも反する。
 *    ここでは外側 <button> を「側面」、内側 <span> を「面」とし、外側の pb-[Npx] が
 *    そのまま厚みの実体になる。動かすのは面の translateY だけ= transform のみで押し込める。
 *
 * 2. 押し込み量は「厚み − 2px」にする
 *    面を厚みぶん沈めると側面が消えてペラい板に見える。2px 残すと最後まで立体が保たれる。
 *    深さ 4/6/8px に対して沈み 2/4/6px。サイズが変わっても比率ではなく実px差で揃えるのが要点。
 *
 * 3. 「押している」の3状態を同じ transform に集約する
 *    :active(押下中)・aria-pressed(トグルON)・:disabled(押せない=沈んだまま)は
 *    見た目がすべて「沈んだ面」。group-active / group-aria-pressed / group-disabled に
 *    同じ translate-y を当てれば、状態ごとの分岐スタイルが要らない。
 *    トグルの押し込みは data-* ではなく aria-pressed に紐づけると、支援技術と見た目が
 *    同じ1つの属性で同期する(状態の二重管理が起きない)。
 *
 * 4. スクワークルは corner-shape の漸進的強化で入れる
 *    [corner-shape:squircle] は未対応ブラウザでは丸ごと無視され border-radius の角丸に
 *    フォールバックする。SVG マスクや clip-path と違い、フォーカスリング・背景・
 *    inset shadow がすべて同じ形に追従するので、押し込み表現との相性がよい。
 *
 * 5. 和文の折り返しは [word-break:auto-phrase] に任せる
 *    text-balance は行長を均すだけなので「押し込みで優先度/を伝えるボタン群」のように
 *    助詞の直前で割れる。auto-phrase は文節境界で折るため、狭いカラムでも語が割れない。
 *    未対応ブラウザでは normal にフォールバックするだけで壊れない。
 *
 * 6. ホバーは「全体を持ち上げる」・押下は「面だけ沈める」
 *    ホバーで面を持ち上げると側面が上側にはみ出て、光源が下から当たったように見える。
 *    持ち上げは外側(=ボタン全体)、沈み込みは内側(=面)と、動かす層を分けると破綻しない。
 */

type Tone = "primary" | "ink" | "chalk";
type Size = "sm" | "md" | "lg";

const TONE: Record<Tone, { edge: string; face: string }> = {
  primary: {
    edge: "bg-amber-800",
    face:
      "bg-amber-600 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3)] group-hover:bg-amber-500",
  },
  ink: {
    edge: "bg-gray-950",
    face:
      "bg-gray-900 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.16)] group-hover:bg-gray-800",
  },
  chalk: {
    edge: "bg-gray-300",
    face:
      "bg-white text-gray-900 shadow-[inset_0_0_0_1px_rgb(229_231_235)] group-hover:bg-gray-50",
  },
};

const SIZE: Record<
  Size,
  { radius: string; edge: string; face: string; iconOnly: string; press: string; icon: string }
> = {
  sm: {
    radius: "rounded-[10px]",
    edge: "pb-[4px]",
    face: "h-9 gap-1.5 px-4 text-xs",
    iconOnly: "h-9 w-9 px-0",
    press: "group-active:translate-y-[2px] group-aria-pressed:translate-y-[2px] group-disabled:translate-y-[2px]",
    icon: "size-3.5",
  },
  md: {
    radius: "rounded-[13px]",
    edge: "pb-[6px]",
    face: "h-11 gap-2 px-5 text-sm",
    iconOnly: "h-11 w-11 px-0",
    press: "group-active:translate-y-[4px] group-aria-pressed:translate-y-[4px] group-disabled:translate-y-[4px]",
    icon: "size-4",
  },
  lg: {
    radius: "rounded-[16px]",
    edge: "pb-[8px]",
    face: "h-13 gap-2.5 px-7 text-base",
    iconOnly: "h-13 w-13 px-0",
    press: "group-active:translate-y-[6px] group-aria-pressed:translate-y-[6px] group-disabled:translate-y-[6px]",
    icon: "size-[18px]",
  },
};

type PushButtonProps = {
  children?: React.ReactNode;
  tone?: Tone;
  size?: Size;
  /** アイコンのみのボタン。必ず label(読み上げ名)を渡す */
  iconOnly?: boolean;
  label?: string;
  pressed?: boolean;
  disabled?: boolean;
  round?: boolean;
  onClick?: () => void;
};

function PushButton({
  children,
  tone = "primary",
  size = "md",
  iconOnly = false,
  label,
  pressed,
  disabled,
  round = false,
  onClick,
}: PushButtonProps) {
  const t = TONE[tone];
  const s = SIZE[size];
  // 角丸ユーティリティ同士の衝突(rounded-[13px] と rounded-full)を避けるため、
  // 形は「半径 + corner-shape」を1つの文字列に組み立てて排他に当てる。
  const shape = round ? "rounded-full" : `${s.radius} [corner-shape:squircle]`;

  return (
    <button
      type="button"
      aria-label={label}
      aria-pressed={pressed}
      disabled={disabled}
      onClick={onClick}
      className={`group inline-flex select-none transition-transform duration-150 ease-out hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 active:translate-y-0 disabled:cursor-not-allowed disabled:bg-gray-300 disabled:hover:translate-y-0 motion-reduce:transition-none ${s.edge} ${shape} ${
        pressed ? "bg-amber-900" : t.edge
      }`}
    >
      <span
        className={`flex items-center justify-center font-semibold tracking-wide whitespace-nowrap transition-[transform,background-color] duration-150 ease-out group-disabled:bg-gray-200 group-disabled:text-gray-400 group-disabled:shadow-none motion-reduce:transition-none ${
          iconOnly ? s.iconOnly : s.face
        } ${shape} ${s.press} ${
          pressed
            ? "bg-amber-700 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.24)] group-hover:bg-amber-700"
            : t.face
        }`}
      >
        {children}
      </span>
    </button>
  );
}

function Row({ index, title, note, children }: { index: string; title: string; note: string; children: React.ReactNode }) {
  return (
    <div className="grid gap-3 border-t border-gray-200 py-6 sm:grid-cols-[9rem_1fr] sm:gap-8">
      <div className="sm:pt-1">
        <p className="font-display text-[10px] font-bold tracking-[0.22em] text-amber-600 uppercase">{index}</p>
        <p className="mt-1 text-xs font-bold text-gray-900">{title}</p>
        <p className="mt-0.5 text-[11px] leading-relaxed text-gray-500">{note}</p>
      </div>
      <div>{children}</div>
    </div>
  );
}

function IconArrow({ className }: { className: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d="M4 10h11M11 6l4 4-4 4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconDownload({ className }: { className: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d="M10 3v9M6.5 8.5 10 12l3.5-3.5M4 15.5h12" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconBookmark({ className, filled }: { className: string; filled?: boolean }) {
  return (
    <svg
      className={className}
      viewBox="0 0 20 20"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth="2"
      aria-hidden="true"
    >
      <path d="M5.5 3.5h9v13l-4.5-3.2-4.5 3.2z" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconMinus({ className }: { className: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d="M5 10h10" strokeLinecap="round" />
    </svg>
  );
}

function IconPlus({ className }: { className: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d="M10 5v10M5 10h10" strokeLinecap="round" />
    </svg>
  );
}

export default function PressDepthButton() {
  const [saved, setSaved] = useState(false);
  const [count, setCount] = useState(2);

  return (
    <section className="w-full max-w-[660px] text-gray-900">
      <header className="pb-6">
        <p className="font-display text-[10px] font-bold tracking-[0.28em] text-amber-600 uppercase">Press Depth</p>
        <h2 className="mt-2 text-lg font-bold tracking-tight [word-break:auto-phrase]">押し込みで優先度を伝えるボタン群</h2>
        <p className="mt-1.5 text-[13px] leading-relaxed text-gray-500 [word-break:auto-phrase]">厚み・面・沈み込みの3値だけで階層とフィードバックを設計する。動かすのは transform のみ。</p>
      </header>

      <Row index="01" title="トーン" note="厚みは共通・面の色で分ける">
        <div className="flex flex-wrap items-end gap-3">
          <PushButton tone="primary">お問い合わせ</PushButton>
          <PushButton tone="ink">資料請求</PushButton>
          <PushButton tone="chalk">あとで見る</PushButton>
        </div>
      </Row>

      <Row index="02" title="スケール" note="厚み 4 / 6 / 8px に対応">
        <div className="flex flex-wrap items-end gap-3">
          <PushButton size="sm">スモール</PushButton>
          <PushButton size="md">ミディアム</PushButton>
          <PushButton size="lg">ラージ</PushButton>
        </div>
      </Row>

      <Row index="03" title="アイコン" note="正方形・円形も同じ規則">
        <div className="flex flex-wrap items-end gap-3">
          <PushButton tone="ink">
            <IconDownload className={SIZE.md.icon} />
            ダウンロード
          </PushButton>
          <PushButton tone="chalk">
            詳しく見る
            <IconArrow className={SIZE.md.icon} />
          </PushButton>
          <PushButton iconOnly label="次のページへ">
            <IconArrow className={SIZE.md.icon} />
          </PushButton>
          <PushButton iconOnly round tone="chalk" label="前のページへ">
            <IconArrow className={`${SIZE.md.icon} rotate-180`} />
          </PushButton>
        </div>
      </Row>

      <Row index="04" title="状態" note="トグルONも無効も沈んだ面">
        <div className="flex flex-wrap items-end gap-3">
          <PushButton pressed={saved} onClick={() => setSaved((v) => !v)}>
            <IconBookmark className={SIZE.md.icon} filled={saved} />
            {saved ? "保存済み" : "保存する"}
          </PushButton>
          <PushButton tone="chalk" disabled>
            送信できません
          </PushButton>
        </div>
      </Row>

      <Row index="05" title="連打" note="境界では無効の面へ落ちる">
        <div className="inline-flex items-center gap-4">
          <PushButton size="sm" tone="chalk" iconOnly label="数量を1つ減らす" disabled={count <= 1} onClick={() => setCount((v) => Math.max(1, v - 1))}>
            <IconMinus className={SIZE.sm.icon} />
          </PushButton>
          <p className="font-display min-w-10 text-center text-2xl font-bold tabular-nums" aria-live="polite">
            {count}
          </p>
          <PushButton size="sm" iconOnly label="数量を1つ増やす" disabled={count >= 9} onClick={() => setCount((v) => Math.min(9, v + 1))}>
            <IconPlus className={SIZE.sm.icon} />
          </PushButton>
        </div>
      </Row>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/press-depth-button.json