A tactile button surface as a small, framework-agnostic CSS primitive. One stylesheet, no JavaScript, no dependencies.
<link rel="stylesheet" href="pressable.css">
<button class="pressable" type="button">Continue</button>
<button class="pressable" type="button" aria-pressed="true">Selected</button>That is the whole thing. Copy pressable.css into your project or take it from
a release. No package, no build step, and no upstream to stay
in sync with: once the file is in your repo it is yours to edit.
One thing to check if you have a CSS reset
All rules live in @layer pressable so this file never beats your own
overrides. But unlayered styles always beat layered ones, so an unlayered
reset (button { padding: 0 }, border-radius: 0) wins over the normalization
the height contract depends on.
Layer your reset too. The statement must be parsed before pressable.css
loads — after, and the real order becomes pressable, reset, app: your reset
wins, silently.
<style>@layer reset, pressable, app;</style> <!-- first -->
<link rel="stylesheet" href="reset.css">
<link rel="stylesheet" href="pressable.css">
<link rel="stylesheet" href="app.css">Tailwind v4 registers theme, base, components, utilities before your
imports, so importing this file after would make pressable the highest layer
and class="pressable rounded-md" would lose. Declare the order first:
@layer theme, base, pressable, components, utilities;
@import "tailwindcss";
@import "./pressable.css";<button class="pressable" type="button">Continue</button>
<a class="pressable" href="/recent" aria-current="page">Recent</a>
<button class="pressable" type="button" aria-pressed="true">
<span class="pressable__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h9l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z"/>
</svg>
</span>
<span class="pressable__label">Recent</span>
</button>- Only
.pressableis required; the slots exist for predictable geometry, and direct text content renders correctly without them. - Written against
<button>and<a>.<input type="submit">and<input type="button">work (appearanceis reset) but cannot hold children, so they get no slots. .pressable__iconis a fixed--pressable-icon-sizebox. A direct-child<svg>is sized down to fill it even with its ownwidth/height; an<img>, or an SVG in your own wrapper, keeps its intrinsic size.
The stylesheet reads attributes and never mutates them. Your toggle, tab implementation, or router sets them.
| Attribute | Shows the pressed surface |
|---|---|
aria-pressed="true" |
yes |
aria-selected="true" |
yes |
aria-current, any value except false or empty |
yes |
data-pressed, present or "true", but not "false" |
yes |
:active |
yes, as a temporary preview |
The two data-shaped hooks read opposite ways round, deliberately: a valueless
data-pressed counts as pressed because that is what libraries emit; a
valueless aria-current counts as not current because ARIA says empty is
false.
Vendor attributes (Radix data-state="on", Headless UI data-selected) are
not enumerated here. State is named in exactly one place — the :is() list
under /* ---- State ---- */. You own the file, so add yours:
.pressable:is(
[aria-pressed="true"],
[aria-selected="true"],
[aria-current]:not([aria-current="false"], [aria-current=""]),
[data-pressed]:not([data-pressed="false"]),
[data-state="on"] /* ← your library */
) {Background, shadow, offset and forced-colors fill then follow automatically: they read a private custom property rather than repeating the list.
If you cannot edit the file, pass data-pressed through instead — most
libraries forward unknown props to the DOM:
<Toggle pressed={on} onPressedChange={setOn} data-pressed={on || undefined}>undefined rather than false leaves the attribute off entirely, which is
what the selector expects.
Set these on .pressable or any ancestor — they are read with fallbacks
and never declared on the element, so container-level theming works.
| Property | Type | Default | Notes |
|---|---|---|---|
--pressable-height |
<length> |
2.5rem |
applied as min-block-size |
--pressable-radius |
<length> |
2.5rem |
|
--pressable-padding-inline |
<length> |
0.875rem |
|
--pressable-padding-block |
<length> |
0 |
raise it if labels wrap |
--pressable-gap |
<length> |
0.25rem |
icon-to-label |
--pressable-font-size |
<length> |
0.875rem |
set, not inherited |
--pressable-line-height |
<number> |
1.286 |
18px at the default font size |
--pressable-font-weight |
<number> |
400 |
|
--pressable-icon-size |
<length> |
1.25rem |
|
--pressable-text-color |
<color> |
#222 |
|
--pressable-focus-color |
<color> |
#1a1a1a |
|
--pressable-raised-background |
<image> |
light gradient | |
--pressable-pressed-background |
<image> |
light gradient | |
--pressable-raised-shadow |
<shadow-list> |
7 shadows | outer and inset in one list |
--pressable-pressed-shadow |
<shadow-list> |
9 shadows | outer and inset in one list |
--pressable-offset |
<length> |
2px |
vertical travel when pressed |
--pressable-duration |
<time> |
220ms |
|
--pressable-easing |
<easing-function> |
cubic-bezier(0.1, 0.9, 0.2, 1) |
|
--pressable-press-scale |
<number> |
0.989 |
scale while held, on every press |
--pressable-press-duration |
<time> |
120ms |
faster than the state change |
--pressable-row-gap |
<length> |
0.5rem |
|
--pressable-row-clearance |
<length> |
1.25rem |
block axis, see Row |
--pressable-row-clearance-inline |
<length> |
0.5rem |
inline axis, keeps the first focus ring reachable |
Each <shadow-list> carries a whole state, outer and inset together, so
retheming means replacing the recipe rather than tinting it. Start from the
dark preset.
.dark {
--pressable-text-color: #f2f2f2;
--pressable-focus-color: #7fb2ff;
--pressable-raised-background: linear-gradient(175deg, #3a3a3c 8%, #2c2c2e 53%);
--pressable-pressed-background: linear-gradient(163deg, #1f1f21 17%, #262628 82%);
--pressable-raised-shadow:
0 3px 2.5px rgb(0 0 0 / 35%),
0 1px 1px rgb(0 0 0 / 40%),
0 0.8px 0.4px rgb(0 0 0 / 40%),
inset 0 1px 0.5px rgb(255 255 255 / 14%),
inset 0 10px 15px rgb(0 0 0 / 8%),
inset 0 -1.5px 0.8px rgb(255 255 255 / 6%),
inset 0 -1.5px 0.75px rgb(0 0 0 / 55%);
--pressable-pressed-shadow:
0 1px 0.5px rgb(255 255 255 / 8%),
0 -0.5px 1px rgb(0 0 0 / 30%),
0 -1.2px 0.5px 1px rgb(0 0 0 / 15%),
inset -0.2px -1px 1px rgb(255 255 255 / 8%),
inset 0.5px 0.7px 2.5px rgb(0 0 0 / 55%),
inset -1px -3px 8px rgb(0 0 0 / 25%),
inset 0.5px 2px 4px rgb(0 0 0 / 35%),
inset 1px 6px 6px 2px rgb(0 0 0 / 30%);
}The press scale is geometric, so it needs no dark variant. This preset drops
the light default's 0 8px 16px diffuse drop shadow, which does nothing over
dark — and since that entry is what sets --pressable-row-clearance, a dark
row can use a smaller clearance.
.toolbar {
--pressable-height: 3rem;
--pressable-radius: 0.5rem;
--pressable-font-size: 1rem;
--pressable-offset: 3px;
--pressable-row-clearance: 1.375rem; /* offset grew, so clearance must too */
}background-image is not interpolable, so easing the colour means painting
both surfaces and fading between them. Two pseudo-element layers, which is why
this is a recipe and not the default. Adding box-shadow to the transition is
not a shortcut: the default lists mismatch on inset at index 3, so the
whole list interpolates discretely and cuts hard at 50%.
This takes over the surface, so the two shadow tokens no longer apply:
.pressable {
position: relative;
isolation: isolate;
background: none;
/* outer shadows only; the insets move to the layers below */
box-shadow:
0 3px 2.5px rgb(0 0 0 / 10%),
0 1px 1px rgb(0 0 0 / 15%),
0 0.8px 0.4px rgb(0 0 0 / 15%);
}
.pressable::before,
.pressable::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
}
/* Only one opacity animates: both layers are opaque over the same box, so the
pressed layer at 1 fully occludes the raised one. Fading both in opposite
directions would show the page through the control mid-transition. */
.pressable::before {
background: linear-gradient(175.11deg, #f3f3f3 8.327%, #fff 53.126%);
box-shadow:
inset 0 1px 1.5px #fff,
inset 0 10px 15px rgb(58 58 58 / 2%),
inset 0 -1.5px 0.8px rgb(255 255 255 / 60%),
inset 0 -1.5px 0.75px rgb(0 0 0 / 30%);
}
.pressable::after {
background: linear-gradient(163.04deg, #fff 16.985%, #f5f5f5 82.516%);
box-shadow:
inset -0.2px -1px 1px #fff,
inset 0.5px 0.7px 2.5px rgb(0 0 0 / 20%),
inset -1px -3px 8px rgb(0 0 0 / 5%),
inset 0.5px 2px 4px rgb(0 0 0 / 10%),
inset 1px 6px 6px 2px rgb(0 0 0 / 10%);
opacity: 0;
transition: opacity var(--pressable-duration, 220ms)
var(--pressable-easing, cubic-bezier(0.1, 0.9, 0.2, 1));
}
.pressable:is(
[aria-pressed="true"],
[aria-selected="true"],
[aria-current]:not([aria-current="false"], [aria-current=""]),
[data-pressed]:not([data-pressed="false"])
)::after,
.pressable:not(:disabled, [aria-disabled="true"]):active::after {
opacity: 1;
}While this is in effect, never set background on .pressable: the layers sit
at negative z-index, so the element's own background paints over both.
- One element. Both surfaces are a
backgroundplus onebox-shadowlist that swap on the state selector. No pseudo-elements, no wrapper. - The surface snaps; the motion is geometry. Gradients don't interpolate,
so the swap is always instant. The motion lives in position instead: a drop
of
--pressable-offsetover 220ms and a scale over 120ms, as the reference does. - State is enumerated once, as private
--_properties that surface, offset and forced-colors all read. - The press is a scale, not a colour change, so pressing an already-pressed control still does something.
.pressable-row scrolls horizontally. A scroll container clips anything
painting outside its padding box on both axes, so the row pads itself 20px
block (covering the pressed drop shadow's 18px) and 8px inline. The inline edge
matters more than it looks: scrollLeft cannot go negative, so a focus ring
clipped off the first control is unreachable, not merely out of view.
.pressable-row--bleed pulls that clearance back out of layout with
negative margins, so the pills sit flush with the container edge as the
reference does. It is opt-in because the row's border box then overlaps its
neighbours and intercepts pointer events there — leave 20px clear above and
below, 8px either side, and raise --pressable-row-clearance if you raise
--pressable-offset. If the overlap won't do, delete .pressable-row and pad
your own scroll container; nothing else depends on it.
- You own behavior.
aria-disabled="true"is a visual hook only and does not prevent activation. Use nativedisabledunless you suppress it yourself. aria-selectedis a styling hook, not a tab widget. A real tab needs the fulltablist/tabpanel/ roving-focus / keyboard contract, which is yours.- Selection groups are not toggles.
aria-pressedmeans an independent on/off control; a row where exactly one item is always selected is radio or tab semantics, needing a group role and name, rovingtabindexand arrow keys — none of which this file provides. The demo uses independent toggles. - The focus ring is
:focus-visible, 2px outline at 2px offset. - The 40px control exceeds the WCAG 2.2 AA 24×24px target minimum; 44px is a
reasonable enhanced target via
--pressable-height. - The default palette is not a blanket WCAG guarantee. It reproduces an observed surface; override the colour tokens where you need more contrast.
- Reduced motion removes the transition, not the 2px end state. A static displacement is not a vestibular trigger, and removing it would leave these users with a weaker state distinction than everyone else.
- There is no hover state. The reference is a touch surface, and a raised/pressed pair has no obvious third appearance.
- Disabled controls render at
opacity: 0.45, roughly 3:1 on white. WCAG exempts inactive controls; raise it if you need them readable. - Text is
user-select: none, so a long-press does not raise the iOS callout. Remove it if your labels are meant to be copied.
My pills look unstyled, or the wrong height. An unlayered reset is beating
@layer pressable. See Quick start — the layer statement must
be parsed before this file loads.
My Radix / Headless UI / Ark toggle does not light up. Only the attributes
in the State table are read. Add one selector, or emit
data-pressed.
My row swallows clicks meant for the thing above it. You have
.pressable-row--bleed on. Drop it, or keep 20px clear above and below.
A <svg> icon overflows its slot. The sizing rule only matches a direct
child of .pressable__icon.
aria-current is not showing as current. An empty value counts as false,
in ARIA and here.
My label wraps to three lines and touches the rounded corner. Past 80px
tall, border-radius: 2.5rem stops being clamped to half the height and the
corner intrudes about a pixel into the first line. Two lines are fine; either
lever fixes three:
.pressable { --pressable-radius: 1rem; } /* stop the corner growing */
.pressable { --pressable-padding-block: 0.5rem; } /* or move the text in */Safari 15.4+, Chrome 104+, Firefox 97+. The binding features are @layer
and the individual translate property. There is no fallback below this
floor: an engine that does not recognise @layer drops the entire file.
| Status | |
|---|---|
| Chrome | verified: geometry, tokens, state, focus, overflow |
| Safari, iOS Safari | verified |
| Firefox | verified |
| RTL | verified: row order flips, logical properties hold |
| Forced colors, 200% zoom | not yet verified |
Please file an issue if you find a break in the unverified row. Known behaviours, not defects:
- Parity is asserted at 2× and 3× DPR, approximate at 1× — the sub-pixel shadow offsets round differently by engine and DPR. Inherent to the technique.
box-shadowis dropped in forced-colors mode, so the file falls back to real borders and the systemHighlightfill..pressable-rowhides its scrollbar for layout consistency, removing a scroll affordance on desktop.- RTL does not mirror the gradient angle.
Issues and PRs welcome, particularly forced-colors, RTL and zoom verification.
The only tool is stylelint, installed ad hoc by CI — no package.json, no
lockfile:
npm install --no-save stylelint@16 stylelint-config-standard@36
npx stylelint "**/*.css"demo/index.html links ../pressable.css, so serve the repository root:
python3 -m http.server 8000 # then open /demo/MIT. See LICENSE.
