Nº 163 of 520
3D keycap press buttons
Chunky keycap buttons with a solid bottom edge that physically sink 6px on press, latching icon keys, and keyboard legends that press them too.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "3D keycap press buttons" (piece button-3d-press) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 163 · "3D keycap press buttons" · www.designlounge.live -->
# 3D keycap press buttons
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A button set that behaves like mechanical keycaps. Each button has a face and a solid bottom edge drawn with a zero-blur offset shadow. On press the face travels down onto the edge (6px at medium size) in 60ms and springs back up in 220ms on release, so a click feels like it bottomed out, without any sound. Three faces (orange primary, charcoal, cream) come in three sizes, plus square 60px icon keys. One icon key is a toggle that latches half-way down with a small LED, like a caps-lock key. Every live key carries a tiny mono legend in its top-left corner, and pressing that letter on a real keyboard sinks the on-screen key. A state sheet under the live row shows each face at default, hover, pressed, disabled and focus.
## Reference behaviour
1. First frame: the putty page with a centred deck card. Header reads "Klakk." with an orange-brown full stop, a mono subline, and a readout pill on the right: "Last: none yet · 0 presses".
2. Live row, left group "Text keys · L / M / M / S": "Ship build" (orange, large, legend S), "Run tests" (charcoal, medium, play icon, legend R), "Save draft" (cream, medium, legend D), "Cancel" (cream, small, legend X).
3. Live row, right group "Icon keys · 60px": Play preview (charcoal, P), Mute microphone (cream toggle with LED, M), New track (cream, N), Undo (orange, Z).
4. Hover: the key rises 1px and its edge grows by 1px.
5. Pointer down: the face drops to `translateY(depth − 1px)`, the edge collapses to 1px, the drop shadow tightens. 60ms, standard easing.
6. Release: the face springs back to rest over 220ms on an overshooting curve.
7. Each activation updates the readout: "Last: Ship build · 1 press", pluralised after the first.
8. The microphone key toggles `aria-pressed`. When on it latches at half depth (3px) and its LED turns orange with a soft 2px halo; the readout says "(on)" or "(off)".
9. Pressing S, R, D, X, P, M, N or Z on the keyboard (no modifier) adds the pressed class on keydown and fires the key on keyup. Holding the letter keeps it down; auto-repeat is ignored.
10. Enter on a focused key also shows the pressed travel (browsers give Space an `:active` state but not Enter).
11. State sheet below: four rows (Primary, Dark, Cream, Icon · latched) by five columns (Default, Hover, Pressed, Disabled, Focus). The sheet is inert reference art.
12. Reduced motion: no transitions and no hover lift. The pressed position still changes so the state reads.
## Structure
```
1280 × 800, deck centred, width min(1040px, 100% ), radius 28px, padding 28/32/30
┌──────────────────────────────────────────────────────────────────────────┐
│ Klakk. (30px 800) [ Last: none yet · 0 ] │
│ mono subline 12px readout, inset pill │
│──────────────────────────── 1px rule ────────────────────────────────────│
│ TEXT KEYS · L / M / M / S ICON KEYS · 60PX │
│ [ Ship build ] [▷ Run tests] [ Save draft ] [Cancel] [▷][mic][+][undo] │
│ 60px tall 52px 52px 40px 60×60 each │
│──────────────────────────── 1px rule ────────────────────────────────────│
│ STATES │
│ DEFAULT HOVER PRESSED DISABLED FOCUS │
│ Primary [Ship] [Ship] [Ship] [Ship] [Ship] 44px keys │
… 161 more lines. Copy or download for the full brief. Loading the source…Brief
201 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A button set that behaves like mechanical keycaps. Each button has a face and a solid bottom edge drawn with a zero-blur offset shadow. On press the face travels down onto the edge (6px at medium size) in 60ms and springs back up in 220ms on release, so a click feels like it bottomed out, without any sound. Three faces (orange primary, charcoal, cream) come in three sizes, plus square 60px icon keys. One icon key is a toggle that latches half-way down with a small LED, like a caps-lock key. Every live key carries a tiny mono legend in its top-left corner, and pressing that letter on a real keyboard sinks the on-screen key. A state sheet under the live row shows each face at default, hover, pressed, disabled and focus.
Reference behaviour
- First frame: the putty page with a centred deck card. Header reads “Klakk.” with an orange-brown full stop, a mono subline, and a readout pill on the right: “Last: none yet · 0 presses”.
- Live row, left group “Text keys · L / M / M / S”: “Ship build” (orange, large, legend S), “Run tests” (charcoal, medium, play icon, legend R), “Save draft” (cream, medium, legend D), “Cancel” (cream, small, legend X).
- Live row, right group “Icon keys · 60px”: Play preview (charcoal, P), Mute microphone (cream toggle with LED, M), New track (cream, N), Undo (orange, Z).
- Hover: the key rises 1px and its edge grows by 1px.
- Pointer down: the face drops to
translateY(depth − 1px), the edge collapses to 1px, the drop shadow tightens. 60ms, standard easing. - Release: the face springs back to rest over 220ms on an overshooting curve.
- Each activation updates the readout: “Last: Ship build · 1 press”, pluralised after the first.
- The microphone key toggles
aria-pressed. When on it latches at half depth (3px) and its LED turns orange with a soft 2px halo; the readout says “(on)” or “(off)”. - Pressing S, R, D, X, P, M, N or Z on the keyboard (no modifier) adds the pressed class on keydown and fires the key on keyup. Holding the letter keeps it down; auto-repeat is ignored.
- Enter on a focused key also shows the pressed travel (browsers give Space an
:activestate but not Enter). - State sheet below: four rows (Primary, Dark, Cream, Icon · latched) by five columns (Default, Hover, Pressed, Disabled, Focus). The sheet is inert reference art.
- Reduced motion: no transitions and no hover lift. The pressed position still changes so the state reads.
Structure
1280 × 800, deck centred, width min(1040px, 100% ), radius 28px, padding 28/32/30
┌──────────────────────────────────────────────────────────────────────────┐
│ Klakk. (30px 800) [ Last: none yet · 0 ] │
│ mono subline 12px readout, inset pill │
│──────────────────────────── 1px rule ────────────────────────────────────│
│ TEXT KEYS · L / M / M / S ICON KEYS · 60PX │
│ [ Ship build ] [▷ Run tests] [ Save draft ] [Cancel] [▷][mic][+][undo] │
│ 60px tall 52px 52px 40px 60×60 each │
│──────────────────────────── 1px rule ────────────────────────────────────│
│ STATES │
│ DEFAULT HOVER PRESSED DISABLED FOCUS │
│ Primary [Ship] [Ship] [Ship] [Ship] [Ship] 44px keys │
│ Dark [Run] … │
│ Cream [Save] … │
│ Icon·latch [mic] … 48×48 │
└──────────────────────────────────────────────────────────────────────────┘
- The deck is
main, labelled by theh1wordmark. - The readout is a
pwitharia-live="polite". - The live row is a
section aria-label="Try the buttons"holding two groups ofbutton type="button". - Icon keys have
aria-label; their SVG and legend spans arearia-hidden. - The state sheet is a
sectionwith anh2“States”, a visually hidden sentence describing it, and a grid withinertandaria-hidden="true".
Tokens
:root {
--bg: #e7e1d5; /* page putty */
--deck: #d6cebf; /* stage card */
--deck-lo: #c4bbaa; /* deck bottom inner lip */
--ink: #1f1b17; /* text, focus ring */
--ink-2: #5a5248; /* captions, 5:1 on deck */
--line: #c2b8a6; /* hairlines */
--cream: #f7f2e8; --cream-edge: #b8ac98;
--orange: #ff6b1a; --orange-edge: #b8460b; /* primary */
--char: #2b2825; --char-edge: #0e0c0b;
--off: #e4ddd0; --off-edge: #c9c0b1; --off-ink: #8f867a; /* disabled */
--sans: "Gabarito", system-ui, sans-serif;
--mono: "Reddit Mono", ui-monospace, monospace;
--press: cubic-bezier(.2,.7,.2,1);
--spring: cubic-bezier(.34,1.56,.64,1);
}
Sizes (height / padding-x / font / radius / depth --d):
| Size | Height | Padding | Font | Radius | Depth |
|---|---|---|---|---|---|
| L | 60px | 30px | 18px | 14px | 7px |
| M | 52px | 24px | 16px | 12px | 6px |
| S | 40px | 16px (20px left) | 14px | 10px | 4px |
| Icon | 60×60 | 0 | — | 14px | 6px |
| Sheet | 44px (icon 48) | 18px | 15px | 12px | 5px |
Spacing: 6, 10, 12, 14, 16, 20, 28, 30. Every key reserves margin-bottom: var(--d) so the edge never collides with the next row.
Typography
| Role | Family | Size | Weight | Tracking | Case |
|---|---|---|---|---|---|
| Wordmark | Gabarito | 30px / 1 | 800 | −0.03em | Title |
| Key label | Gabarito | 14 / 16 / 18px | 700 | −0.01em | Sentence |
| Subline, readout | Reddit Mono | 12px | 500 (values 700) | 0.02em | Sentence |
| Group captions, column heads | Reddit Mono | 10px | 500 | 0.08em | Upper |
| Key legend | Reddit Mono | 9px | 700 | 0.04em | Upper, 55% opacity |
Ink on orange is about 7:1. Never put cream text on the orange face; it is under 3:1.
Motion
| Thing | Trigger | Property | From → to | Duration | Easing | Reduced motion |
|---|---|---|---|---|---|---|
| Hover lift | :hover | translateY, edge | 0 → −1px, d → d+1 | 220ms | —spring | none |
| Press | :active / .is-pressed | translateY, edge, shadow | 0 → d−1, d → 1px | 60ms | —press | instant |
| Release | pointer up / keyup | same, back | d−1 → 0 | 220ms | —spring (overshoot) | instant |
| Latch | aria-pressed=true | translateY, edge | → d/2 | 220ms | —spring | instant |
| LED | aria-pressed | background | grey → orange | 120ms | —press | instant |
The press and release use different durations. Set the long spring on the base rule and override transition-duration: 60ms only inside :active, so the browser uses the short one going down and the long one coming back.
States
- Default: face gradient (white 28% → 0 over the top 45%) on the face colour, 1px inner top highlight, 2px inner bottom shade, solid edge
0 var(--d) 0 var(--edge), plus a soft drop0 (d+5px) 10px −3px rgba(50,35,20,.38). - Hover: up 1px, edge d+1, drop d+7.
- Pressed: down d−1, edge 1px, inner shade
inset 0 2px 4px rgba(0,0,0,.12). - Latched (toggle on): down d/2, edge d/2, LED orange.
- Disabled: face
--off, edge 2px--off-edge, text--off-ink, no gradient, no hover,cursor: not-allowed. - Focus-visible: 3px
--inkoutline, offset 4px, following the radius. - Dark face: top gradient is white 10% instead of 28%, text cream.
Accessibility
- All keys are real
button type="button"elements. Visible text is the name; icon keys usearia-label(“Play preview”, “Mute microphone”, “New track”, “Undo”). - The mic key uses
aria-pressedand keeps a constant label. - Readout is
aria-live="polite"and is the only thing announced. - Keyboard: Tab order follows the visual order. Space and Enter activate a focused key and both show the travel. Letter legends fire only without Ctrl, Cmd or Alt, and call
preventDefaultso they do not scroll or type. - The state sheet is
inertso its 20 fake buttons are not in the tab order or the accessibility tree. - Hit targets: smallest live key is 40px tall. Contrast: ink on cream ~15:1, ink on orange ~7:1, cream on charcoal ~13:1, captions
--ink-2on deck ~5:1.
Responsive rules
- ≥1024: as drawn; text keys left, icon keys right on one row.
- 768–1024: the live row wraps; icon keys drop below text keys with their caption.
- <760: deck padding 22/18; the sheet loses its row-label column: row labels span the full width above each row of five, keys shrink to 40px tall with 10px padding, column heads 8px. At 375 nothing overflows.
- Never scale the depth with the viewport. Depth is a size property, not a layout one.
Acceptance checklist
Always
- The bottom edge is a zero-blur box-shadow in a darker shade of the face, not a border or a pseudo-element.
- Press moves the face down by depth − 1px and collapses the edge to 1px; nothing else in the layout moves.
- Press is 60ms; release is 220ms with overshoot.
- Keys reserve
margin-bottomequal to their depth. - Toggle keys latch at half depth and expose
aria-pressed. - Enter shows the same pressed travel as Space.
- Disabled keys are flat (2px edge), muted, and do not lift on hover.
- Focus ring 3px ink, offset 4px, on every live key.
- A states sheet shows default, hover, pressed, disabled, focus for each face, and is inert.
- Reduced motion removes transitions and the hover lift.
This demo
- Wordmark “Klakk.” and readout “Last: none yet · 0 presses”.
- Live keys: Ship build (L, orange, S), Run tests (M, charcoal, R), Save draft (M, cream, D), Cancel (S, cream, X), plus icon keys P, M (toggle), N, Z.
- Orange
#ff6b1awith edge#b8460b; cream#f7f2e8with edge#b8ac98. - Pressing the M key on a keyboard latches the mic and the readout reads “Mute microphone (on)”.
Implementation notes
The keycap in one rule. Depth is a custom property so every size reuses the same shadows:
.key { --d: 6px; margin-bottom: var(--d); border-radius: 12px;
background: linear-gradient(180deg, rgba(255,255,255,.28), transparent 45%), var(--face);
box-shadow: inset 0 1px 0 rgba(255,255,255,.55), inset 0 -2px 0 rgba(0,0,0,.07),
0 var(--d) 0 var(--edge), 0 calc(var(--d) + 5px) 10px -3px rgba(50,35,20,.38);
transition: transform 220ms var(--spring), box-shadow 220ms var(--spring); }
.key:active, .key.is-pressed { transform: translateY(calc(var(--d) - 1px));
transition-duration: 60ms; transition-timing-function: var(--press);
box-shadow: inset 0 2px 4px rgba(0,0,0,.12), 0 1px 0 var(--edge), 0 2px 3px -1px rgba(50,35,20,.4); }
Keyboard legends. Press on keydown, fire on keyup, ignore repeats:
document.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const b = byKey[e.key.toLowerCase()]; if (!b) return;
e.preventDefault(); if (!e.repeat) b.classList.add('is-pressed');
});
document.addEventListener('keyup', e => {
const b = byKey[e.key.toLowerCase()];
if (b?.classList.contains('is-pressed')) { b.classList.remove('is-pressed'); fire(b); }
});
Mirror the same add/remove for Enter on the focused key, because Enter never produces :active.
Common mistakes:
- Using
border-bottomfor the edge. It changes the box height on press and shoves siblings. - Moving with
topormargininstead oftransform; it reflows on every press. - Same duration down and up. Down must be near-instant or it feels mushy.
- A blurred shadow as the edge. The edge is solid; only the floor shadow is soft.
- Cream text on orange to “match” the charcoal key.
- Forgetting the margin, so the large key’s edge sits on the caption below it.
Details
- Palette
- Type
- Gabarito · Reddit Mono
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
WickretLet the big words move. Letters can break apart and come back as new words.
All sourcesOn the shelves