BTN-02Elements
Press-Depth 3D Button System
A tactile button family with real thickness and squircle corners: pressing sinks only the face. Three tones, three sizes, icon and icon-only shapes, a toggle, a disabled state, and a stepper all follow one rule (press depth = thickness − 2px). Motion is transform-only, and pressed, toggled-on, and disabled share the same sunken face.
- Added:
- 2026-08-15
- Dependencies:
- None
- tags
- #button #interactive #3d #tactile #squircle #toggle #variants
Preview
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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/press-depth-button.json