Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pressable.css

A tactile button surface as a small, framework-agnostic CSS primitive. One stylesheet, no JavaScript, no dependencies.

A scrolling row of pill buttons, light and dark. Tapping one sinks it into the surface; the whole control squishes slightly on press.

Live demo


Quick start

<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";

Usage

<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 .pressable is 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 (appearance is reset) but cannot hold children, so they get no slots.
  • .pressable__icon is a fixed --pressable-icon-size box. A direct-child <svg> is sized down to fill it even with its own width/height; an <img>, or an SVG in your own wrapper, keeps its intrinsic size.

State

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.

Mapping a component library

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.

Custom properties

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.

Recipes

Dark surface

.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.

Square, larger, branded

.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 */
}

Crossfade the surface

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.

How it works

  • One element. Both surfaces are a background plus one box-shadow list 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-offset over 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.

Row

.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.

Accessibility

  • You own behavior. aria-disabled="true" is a visual hook only and does not prevent activation. Use native disabled unless you suppress it yourself.
  • aria-selected is a styling hook, not a tab widget. A real tab needs the full tablist / tabpanel / roving-focus / keyboard contract, which is yours.
  • Selection groups are not toggles. aria-pressed means 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, roving tabindex and 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.

Troubleshooting

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 */

Browser support

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-shadow is dropped in forced-colors mode, so the file falls back to real borders and the system Highlight fill.
  • .pressable-row hides its scrollbar for layout consistency, removing a scroll affordance on desktop.
  • RTL does not mirror the gradient angle.

Contributing

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/

License

MIT. See LICENSE.

About

A tactile button surface as a single-file CSS primitive. No JavaScript, no dependencies.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages