registry:ui · General
press-button — one physics, the ladder, four effects
The only button in the grammar, and the animation is deliberately quiet: hover never moves the body — the hard shadow alone grows from xs to sm; active presses the body one pixel into the page while the shadow stays exactly where it was. Variants are a prominence ladder, never a color decision: fill for the one action that matters, tonal for the supporting seat, outline for the rest (the default), ghost and link for the quiet seats — link is the grammar's one interaction exception. Semantic color is hue injection through the global tokens: destructive actions fill with the destructive pair, metadata tones down through --jx-tonal, the copied transient is tonal + success. One opt-in effect loop adds attention without breaking the restraint: shimmer (a spark walks the perimeter), pulse (sonar rings breathe outward), rainbow (a gradient flows around the border), ripple (ink expands from the press point) — typed builders with options, all frozen under reduced motion.
$npx jixoai-ui add press-button
https://ui.jixoai.com/r/press-button.json
overview
Overview
One physics for every surface: the press law is elevation-only, the ladder is prominence, semantic hue injects through tokens, and four opt-in effect loops ride the attachment channel.
The press law is the theme's shared .jx-press class,
measured here end to end: rest carries the xs shadow, hover grows it to sm — the body
never moves — and active translates the body 1px into the page while the shadow's
offsets counter-shrink 1px, so the shadow paint stays anchored on screen. Ghost's
none-trio removes all three shadows (the press vector remains, measured 1px with zero
shadow), the flat pose (raised=false) swaps the active shadow for the engrave-tier
inset and nulls the move entirely, and every transition is 150ms ease-out with a
prefers-reduced-motion kill. The variant ladder is prominence, never color: fill for
the one action, tonal supporting, outline default, ghost quiet, link the interaction
exception — no frame, no press shadow, hover underline.
Semantic hue is injection, never a variant: destructive is fill + jx-pair-destructive (fill and ink in one class,
measured black/white), the copied transient is tonal + success. The family re-declares
the fill/tonal seams ON the element from the color-effective carrier (the canvas-bug
law), so an explicit color lane re-tints live and the call-site utilities outrank the
axis default. The popovertarget prop is the native popover invoker, button-only —
declared, forwarded, and measured opening and toggling a panel with zero component
listeners; anchors cannot invoke popovers (the platform's own exclusion).
The eight axes split by role (all measured in the axes table): density is the hit
ladder landing on the WCAG 24px floor at 2xs; radius is the full concentric wiring
(measured 6px live from a 20px-radius, 14px-inset seat); shape multiplies it (the §14
×2 squircle law, measured 10 → 20px); color re-tints through the seam; the theme is
split WITHIN the ladder — paint re-derives under .dark while the outline/ghost label
ink stays a frozen literal; elevation steps only the rest ground; size echoes on the
root (the inline stamp outranks the atom, so the label voice follows); motion rides
the sheet constants. Kinship: button-group (the zone and the join), icon-button (the square idiom's consumer), toggle-group (the pressed state family).
press-button
The press-law button: hover grows the shadow only (xs → sm, the body never moves); active presses the body +1px into the page while the shadow layer stays anchored. The playground rows render from the generated component schema (meta → toJSONSchema); reset returns the schema defaults.
<script lang="ts">
import PressButton, { shimmer, pulse, rainbow, ripple } from '@ui/press-button.svelte';
</script>
<!-- one physics for every variant: hover grows the shadow, active presses -->
<PressButton variant="fill">deploy</PressButton>
<PressButton variant="tonal" class="jx-hue-neutral">invite</PressButton>
<PressButton variant="outline">cancel</PressButton>
<PressButton variant="ghost">dismiss</PressButton>
<!-- destructive ACTION = fill + the destructive pair (statuses use --jx-error instead) -->
<PressButton variant="fill" class="jx-pair-destructive">delete</PressButton>
<PressButton variant="link">details</PressButton>
<!-- copied is not a variant: the transient success state is tonal + injection -->
<PressButton variant="tonal" class="jx-hue-success">copied</PressButton>
<!-- one opt-in effect loop per button — the typed builders ride the
attachment factory; the component tag reaches the managed root
button through the host's rest spread -->
<PressButton variant="fill" {@attach pressEffect(shimmer())}>deploy</PressButton>
<PressButton variant="fill" {@attach pressEffect(pulse({ variant: 'ring' }))}>deploy</PressButton>
<PressButton variant="outline" {@attach pressEffect(rainbow())}>upgrade</PressButton>
<PressButton variant="fill" {@attach pressEffect(ripple({ duration: 800 }))}>deploy</PressButton>
<!-- href renders an anchor; ONLY an absolute http(s) URL opens a new tab -->
<PressButton variant="fill" href="/docs.html">read the docs</PressButton>context
The ambient zone — variant by context
A lone button defaults to outline — the frozen own. The SAME button inside a zone scope defaults to the zone's variant with no prop passed anywhere: explicit ?? ambient zone ?? own, left to right, no exceptions. Two doors set the zone: ButtonVariantScope (zero-DOM — free-floating buttons keep their placement) and ButtonGroup (zone plus the hairline join). Dialog's head and foot zones are the canonical consumers: every unprefixed button inside a dialog is ghost, while the page floor stays outline.
<!-- the zone changes the DEFAULT, not the paint — link is NOT a
zone value (the interaction exception keeps its only route
through the explicit prop); the system story lives in
/docs/context-defaults.html -->
<ButtonVariantScope variant="ghost">
<PressButton>adopts ghost</PressButton>
<PressButton variant="fill">keeps fill</PressButton>
</ButtonVariantScope>demo
Button or anchor
href switches the element from button to anchor. The external law (#5, link.svelte's codified rule): ONLY an absolute http(s) URL is external — app routes and same-document anchors navigate in place; an external href opens a new tab with noreferrer automatically. The label is a snippet, so icons compose inline with the component's own gap.
<script lang="ts">
import PressButton, { shimmer, pulse, rainbow, ripple } from '@ui/press-button.svelte';
</script>
<!-- one physics for every variant: hover grows the shadow, active presses -->
<PressButton variant="fill">deploy</PressButton>
<PressButton variant="tonal" class="jx-hue-neutral">invite</PressButton>
<PressButton variant="outline">cancel</PressButton>
<PressButton variant="ghost">dismiss</PressButton>
<!-- destructive ACTION = fill + the destructive pair (statuses use --jx-error instead) -->
<PressButton variant="fill" class="jx-pair-destructive">delete</PressButton>
<PressButton variant="link">details</PressButton>
<!-- copied is not a variant: the transient success state is tonal + injection -->
<PressButton variant="tonal" class="jx-hue-success">copied</PressButton>
<!-- one opt-in effect loop per button — the typed builders ride the
attachment factory; the component tag reaches the managed root
button through the host's rest spread -->
<PressButton variant="fill" {@attach pressEffect(shimmer())}>deploy</PressButton>
<PressButton variant="fill" {@attach pressEffect(pulse({ variant: 'ring' }))}>deploy</PressButton>
<PressButton variant="outline" {@attach pressEffect(rainbow())}>upgrade</PressButton>
<PressButton variant="fill" {@attach pressEffect(ripple({ duration: 800 }))}>deploy</PressButton>
<!-- href renders an anchor; ONLY an absolute http(s) URL opens a new tab -->
<PressButton variant="fill" href="/docs.html">read the docs</PressButton>demo
The async two-step
loading is an ANCHOR CONTRACT: aria-disabled='true' (the button stays focusable — tab order unchanged, opaque to why it is inert), pointer AND keyboard activation suppressed (Enter/Space no-op), and for href anchors the navigation itself is blocked. The spinner glyph takes the leading lane and the press law holds unchanged — hover grows only the shadow, active still presses +1px. On settle, the one-shot flash() swaps the leading lane to a ✓ check for 1.2s, then the button rests.
press-button · async two-step
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
let deployState = $state<'idle' | 'loading'>('idle');
let deployEcho = $state('idle — press me');
let deployBtn: { flash: (ms?: number) => void } | undefined;
async function deploy(): Promise<void> {
if (deployState === 'loading') return; // the lock itself, from the host side too
deployState = 'loading';
deployEcho = 'loading — presses and Enter/Space are no-ops';
await new Promise((r) => setTimeout(r, 1400));
deployState = 'idle';
deployEcho = 'success flashed ✓ (one-shot, 1.2s), then rest';
deployBtn?.flash();
}
let navLoading = $state(false);
let navEcho = $state('idle — start the fake task, then try the anchor');
function navTask(): void {
if (navLoading) return;
navLoading = true;
navEcho = 'loading — the anchor\'s href navigation is blocked';
setTimeout(() => {
navLoading = false;
navEcho = 'rest — the anchor navigates again';
}, 2500);
}
</script>
<div id="async-demo" class="flex flex-wrap items-center gap-x-8 gap-y-5">
<div class="text-muted-foreground flex items-center gap-2.5 text-xs">
<span>async deploy</span>
<PressButton
bind:this={deployBtn}
variant="fill"
loading={deployState === 'loading'}
onclick={deploy}
>
deploy
</PressButton>
</div>
<div id="async-anchor-demo" class="text-muted-foreground flex items-center gap-2.5 text-xs">
<span>loading anchor</span>
<PressButton variant="tonal" onclick={navTask}>start fake task</PressButton>
<PressButton variant="outline" href="/docs/components.html" loading={navLoading}>
read the docs
</PressButton>
</div>
<span class="text-muted-foreground text-[12.5px]" data-async-echo>{deployEcho}</span>
<span class="text-muted-foreground text-[12.5px]">{navEcho}</span>
</div>law
Why the shadow is the affordance
No fills-in-motion, no glows, no ripple. The one-hue grammar expresses state through elevation alone, so the button reads identically in light and dark themes and survives the hue runtime without a second rule.
-
variantselects only the ladder rung —fill,tonal,outline,ghost, pluslink, the one interaction exception — the press law is the theme's shared.jx-pressclass, one source for every button in the grammar - semantic color is hue injection, never a variant: destructive actions carry
fill+jx-pair-destructive(the pair utility — fill and ink in one class), metadata softens throughjx-hue-neutral, and the copied transient istonal+jx-hue-success - the shadow is the body's own
box-shadow: hover grows it (xs → sm) and nothing else; active slides the body +1px while the shadow's offsets counter-shrink 1px (the theme's*-pressposes) — the shadow's paint never moves on screen, no pseudo layer involved -
effectaccepts ONE typed builder exported from the component's module script —shimmer()(a conic spark walks the perimeter),pulse()(sonar rings from the body's silhouette, three variants),rainbow()(a gradient flows around the border, optional under-glow),ripple()(ink circles from the exact press point, center on keyboard activation) — every loop takes typed options (speed,color,distance, …), modeled on the animation-svelte reference -
hrefswitches the element to an anchor; the external law is a codified regex — ONLY an absolute http(s) URL opens a new tab (withnoreferrer): app routes and same-document anchors keep the same-tab default, so a#sectionpress-button never spawns a tab -
loadingis the async pose with an explicit anchor contract:aria-disabled="true"(focusable — tab order unchanged), pointer AND keyboard activation suppressed (Enter/Space no-op), andhrefnavigation blocked; the spinner glyph takes the leading lane and the press law holds unchanged — pair with the one-shotflash()helper (bind:this) for the ✓ success flash - the label is a snippet, so icons compose inline — spacing comes from the
component's own
gap-2.5 - reduced motion ships inside the
.jx-presslaw:prefers-reduced-motiondrops every press transition to none
types
The variant ladder
Choose the prominence rung first; semantic hue is injected separately through the grammar tokens. Every rung keeps the same hit target and press physics.
press-button · ladder
fill
tonal
outline
ghost
link
semantic injection recipes — hue, not a rung
destructive ACTION
fill + the destructive pair
neutral / meta
tonal + muted-foreground
success status
tonal + success
brand (default hue)
fill, no injection
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
</script>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{#each [
['fill', 'The one action'],
['tonal', 'Supporting seat'],
['outline', 'The rest (default)'],
['ghost', 'Quiet seat'],
['link', 'Inline navigation'],
] as item}
<div class="border border-border/60 p-3">
<PressButton variant={item[0] as 'fill' | 'tonal' | 'outline' | 'ghost' | 'link'}>{item[1]}</PressButton>
<p class="mt-2 text-xs text-muted-foreground">{item[0]}</p>
</div>
{/each}
</div>
<p class="font-nav mb-4 text-[11px] uppercase tracking-[0.24em] text-muted-foreground">
semantic injection recipes — hue, not a rung
</p>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="border border-border/60 p-3">
<PressButton
variant="fill"
class="jx-pair-destructive"
>
delete
</PressButton>
<p class="mt-2 text-xs text-muted-foreground">destructive ACTION<br />fill + the destructive pair</p>
</div>
<div class="border border-border/60 p-3">
<PressButton variant="tonal" class="jx-hue-neutral">invite</PressButton>
<p class="mt-2 text-xs text-muted-foreground">neutral / meta<br />tonal + muted-foreground</p>
</div>
<div class="border border-border/60 p-3">
<PressButton variant="tonal" class="jx-hue-success">copied</PressButton>
<p class="mt-2 text-xs text-muted-foreground">success status<br />tonal + success</p>
</div>
<div class="border border-border/60 p-3">
<PressButton variant="fill">deploy</PressButton>
<p class="mt-2 text-xs text-muted-foreground">brand (default hue)<br />fill, no injection</p>
</div>
</div>usage
Usage
Use the semantic variant and add one typed effect only when the action needs extra attention — compose a button — the full usage file, as the canvas above runs it.
<script lang="ts">
import PressButton, { shimmer, pulse, rainbow, ripple } from '@ui/press-button.svelte';
</script>
<!-- one physics for every variant: hover grows the shadow, active presses -->
<PressButton variant="fill">deploy</PressButton>
<PressButton variant="tonal" class="jx-hue-neutral">invite</PressButton>
<PressButton variant="outline">cancel</PressButton>
<PressButton variant="ghost">dismiss</PressButton>
<!-- destructive ACTION = fill + the destructive pair (statuses use --jx-error instead) -->
<PressButton variant="fill" class="jx-pair-destructive">delete</PressButton>
<PressButton variant="link">details</PressButton>
<!-- copied is not a variant: the transient success state is tonal + injection -->
<PressButton variant="tonal" class="jx-hue-success">copied</PressButton>
<!-- one opt-in effect loop per button — the typed builders ride the
attachment factory; the component tag reaches the managed root
button through the host's rest spread -->
<PressButton variant="fill" {@attach pressEffect(shimmer())}>deploy</PressButton>
<PressButton variant="fill" {@attach pressEffect(pulse({ variant: 'ring' }))}>deploy</PressButton>
<PressButton variant="outline" {@attach pressEffect(rainbow())}>upgrade</PressButton>
<PressButton variant="fill" {@attach pressEffect(ripple({ duration: 800 }))}>deploy</PressButton>
<!-- href renders an anchor; ONLY an absolute http(s) URL opens a new tab -->
<PressButton variant="fill" href="/docs.html">read the docs</PressButton>theming
Density and tokens
The button reads its geometry from the inherited density scale, so one scope change updates every instance together. The canvas dock's density select (xs / sm / default / lg) is the live proof — it re-scopes only the stage; the DensityDemo four-copy hack is retired by it.
flip the dock's density select above to sm — the workbench
canvas re-densifies its own stage (the density scope lands on the stage element only),
never the docs chrome around it. Both theme seats work the same way. The scope ladder
below renders the whole rung set, floor included.
| Token | Default | Source |
|---|---|---|
| --jx-hit | 28 / 32 / 40 / 48px | density |
| --jx-inset | 8 / 8 / 12 / 16px | density |
| --jx-gap | 8 / 8 / 12 / 16px | density |
| --jx-text | 11 / 12 / 13 / 15px | density |
| --jx-line | 16 / 18 / 20 / 24px | density |
| --jx-press-shadow | var(--shadow-xs) | component |
| --jx-press-shadow-hover | var(--shadow-sm) | component |
| --jx-press-shadow-active | var(--shadow-sm-press) | component |
api
Props
The public contract is intentionally small: semantic paint, optional navigation, and the rest lane every arbitrary attribute — the component-tag attachment included — rides to the root.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| variant | 'fill' | 'tonal' | 'outline' | 'ghost' | 'link' | 'outline' · ambient zone | Selects the ladder rung; link is the interaction exception. Omitted → the ambient paint zone (ButtonGroup / variant scope), else the frozen own. Semantic hue injects through --jx-fill/--jx-fill-ink, --jx-tonal, --jx-outline classes at the call site. |
| {@attach …} (component tag) | Attachment<HTMLElement> | — | The effect mount (r4): <PressButton {@attach pressEffect(builder())}> — shimmer, pulse, rainbow, or ripple builders ride the attachment factory; the tag lands at the stamped root through the rest spread. Leaf elements take {@attach pressEffect(fx)} directly. |
| …rest | HTMLAttributes<HTMLElement> | — | The rest lane: arbitrary attributes land verbatim on the root (button or anchor) — the same lane the component-tag attachment rides. |
| href | string | — | Renders an anchor and navigates to the target. |
| loading | boolean | false | The async pose: aria-disabled=true, pointer AND keyboard activation suppressed, href navigation blocked, spinner glyph in the leading lane. Press law holds unchanged. Pair with the one-shot flash() helper (bind:this) on settle. |
| disabled | boolean | false | The native inert pose: the disabled attribute drops the button from the tab order and suppresses activation; on the anchor form it maps to the loading pose’s contract instead (aria-disabled + blocked navigation) — disabled is not an anchor attribute. The button’s paint rides unchanged: no authored disabled face exists (measured). |
| popovertarget | string | — | The native popover invoker association — declared and forwarded on the button form (verified live: click opens and toggles the target panel with zero component listeners); anchors cannot invoke popovers, so the anchor form drops it. Composers set aria-haspopup themselves — it names the panel’s role, not this button’s. |
| external | boolean | auto | Overrides the derived flag. The DEFAULT derives from the href itself (the #5 law): only an absolute http(s) URL is external — app routes and same-document anchors keep the same-tab default. External opens a new tab with noreferrer. |
| onclick | () => void | — | Runs for button activation. |
| type | 'button' | 'submit' | 'button' | Native button type. |
| ariaLabel | string | — | Accessible name override for icon-only use. |
| square | boolean | false | Uses the square hit-target geometry. |
| raised | boolean | 'true' · ambient zone | The physics axis, orthogonal to paint: false is the FLAT texture — no rest/hover shadow, the body never moves, an engrave-tier inset alone creates the press. A zone (ButtonVariantScope, or the joined ButtonGroup — its subtree rides flat while the root carries the one cluster shadow) may scope the default to false; an explicit prop always wins. Inert on link. |
| children * | Snippet | — | Button label and optional inline icon content. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on press-button
Density is the hit ladder down to the WCAG 24px floor at 2xs; radius is the full concentric wiring (measured 6px from a 20px/14px seat); shape multiplies it (the §14 ×2 squircle law, measured 10 → 20px); color re-tints fill/tonal live through the on-element seam; the theme splits WITHIN the ladder (paint re-derives under .dark, outline/ghost label ink stays a frozen literal); elevation steps only the rest ground; size echoes and the voice follows; motion rides the sheet constants.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | CONSUMED — the press geometry rides the kernel channels: min-height, the square pose's width, the voice (--jx-text 13px), inline padding (--jx-inset 12px) and the composed gap. Measured ladder 2xs→lg: hit 24/28/32/40/48px, voice 10/11/12/13/15px, padding 8/8/8/12/16px. THE FLOOR: the 2xs rung puts the button at exactly 24px — the WCAG 2.5.8 target-size minimum. Ambient (attr null, measured) the channels cascade from the ancestor scope; an explicit lane stamps the rung attr and the global scope selector re-scopes in place. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | ROOT ECHO, THE VOICE FOLLOWS — the §11 stamp lands INLINE on the button root, so it outranks the --jx-text atom and the label voice moves with it (measured 18px at size="large" on a large-density seat); hit/width geometry stays the density hit channel. Zero --jx-size-effective readers (grep receipt). Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | CONSUMED TWICE — corner-shape reads --jx-shape-effective (measured superellipse(1) ambient, superellipse(2) at squircle) AND the §14 per-shape factor multiplies the radius in the same consumed calc (the ×2 squircle law measured: radius 10 + squircle → 20px). Ambient round with no supplying ancestor resolves the root 0px invariants. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | CONSUMED — THE CONCENTRIC WIRING: auto (the default) reads §3's max(0px, radius-effective − inset-effective) × the §14 factor against the nearest supplying ancestor (measured 6px live on the dock's driven seat: 20px radius − 14px inset); an explicit lane supplies instead AND resets the inset to 0 (measured radius medium → 8px; radius 10 + squircle → 20px with the ×2 factor); a bare button falls to the root sheet's 0px invariants (measured). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | CONSUMED THROUGH THE SEAM — the family re-declares --jx-fill/--jx-fill-ink/--jx-tonal ON the element from --jx-color-effective (the canvas-bug law: a :root-substituted token would never see an inline carrier), so an explicit hue lane re-tints fill/tonal live; --jx-outline does NOT follow (its historic ground is --border). Call-site hue/pair utilities outrank the axis default (the state-paint law: destructive = fill + jx-pair-destructive, measured black/white). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | SPLIT WITHIN THE LADDER (measured per element under .dark) — the PAINT re-derives: the site's dark scope re-declares the four seam tokens as live chains, so fill re-tints to the drifted dark primary (signature: L/C identical on both sides, hue wall-clock — absolute hue digits don't reproduce across runtime states; the site-local hue runtime, jixoai.css :32-45, rotates the channel) and outline's frame flips to the dark border (white). The LABEL INK stays frozen on every rung — outline/ghost's tokens['--jx-foreground'] is a defineVars :root literal the dark scope does not re-declare (measured oklch(0 0 0) on both sides), and fill's label ink is constant oklch(0 0 0) on light AND on the re-tinted dark fill — the frozen-ink seam extends to the loudest rung, where black-on-dark-primary is the visible contrast cost. system/auto = tree inheritance. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | CONSUMED AT REST — a resolved lane's pair re-points --jx-press-shadow (the press law's GROUND); hover/active keep the family's own anchored signatures (the axis steps the ground, the press law stays the physics). Ghost's none-trio and the flat pose outrank it by css source order — quiet rungs never grow a shadow from the axis. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SHEET CONSTANTS, AXIS UNREAD — the press transitions ride the .jx-press law (150ms ease-out on translate/box-shadow/background/border/color, measured) with the prefers-reduced-motion kill; --jx-motion-effective has zero readers in ui/press-button/ (grep receipt). Number unit: coefficient. |
Receipts: the press ladder (rest xs → hover sm with the body static → active translate 1px 1px with the offsets counter-shrunk), the ghost none-trio (shadow none at rest AND active, the press vector retained), the flat engrave swap (inset + translate none), the density ladder (24/28/32/40/48px; the 2xs stamp measured in place and restored), the concentric seat (radius-effective 20px − inset 14px → 6px computed), the ×2 squircle (radius 10 → 20px, superellipse(2)), the seam re-tint and the .dark split (fill re-tint with L/C identical and hue wall-clock — the runtime rotation caveat; the outline and fill label inks stayed oklch(0 0 0)), the forced-colors trio (link → LinkText; fill → ButtonFace/ButtonText) and the popovertarget open/toggle were measured on this page's served DOM (probe, task 33); the unread-axis and zero-reader rows carry grep receipts over ui/press-button/. The query() seat below rides the md viewport key (48rem) on the consumed density lane.
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (small) applies; at 48rem+ the md case (large)
wins — 32px hits become 48px, the press law unchanged -->
<PressButton density={query({ md: 'large' }, 'small')}>deploy</PressButton>press-button · query()
Media keys are min-width: below 48rem the base (small — 32px hit, 12px voice) applies; at 48rem and wider the md case wins (large — 48px hit, 15px voice). The string lane takes both generics. Resize across 48rem.
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (small) applies; at 48rem+ the md case (large)
wins — 32px hits become 48px, the press law unchanged -->
<PressButton density={query({ md: 'large' }, 'small')}>deploy</PressButton>PressButton · universal props
<!-- the eight-axis surface: named steps · auto (inherit) · exact
numbers · query() for conditional values; auto stamps nothing -->
<PressButton size={14} density="small">px number · sm rung</PressButton>
<PressButton size="large" radius="medium">named steps</PressButton>
<PressButton shape="squircle" radius={10}>squircle ×2</PressButton>
<PressButton radius="auto">concentric auto</PressButton>a11y
Keyboard and semantics
Native buttons and anchors keep their platform behavior; the loading pose is the aria-disabled contract (focusable, opaque to why it is inert); every rung carries a forced-colors degradation and the 2px Highlight focus ring; the hit ladder bottoms at the WCAG 24px floor.
Keyboard
| Key | Action |
|---|---|
| Tab | Move focus to the button or link — loading keeps the tab stop (aria-disabled, never the disabled attribute) |
| Enter / Space | Activate a button — native buttons synthesize click from both, so the activation lock covers pointer and keyboard at one seam |
| Enter | Follow an href rendered as an anchor; while loading the navigation itself is blocked (preventDefault) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | optional | Names icon-only or otherwise unlabeled controls (the ariaLabel prop or the plain attribute — composed, never clobbered). |
| aria-disabled | true while loading | The anchor contract: the element stays focusable (tab order unchanged) and opaque to why it is inert, while pointer, keyboard, and href activation are suppressed. |
| disabled | native attribute (button form) | The native inert pose — dropped from the tab order by the platform; the paint rides unchanged (measured: full fill colors at opacity 1, no authored disabled face). |
| popovertarget | panel id (button form) | The native invoker association — opens and toggles the popover with zero listeners; anchors cannot invoke popovers. Set aria-haspopup on the composing family. |
| forced-colors | per-rung degradation | fill → ButtonFace/ButtonText, tonal/outline/ghost → Canvas/CanvasText (link → LinkText — measured), and the focus law pins to a 2px Highlight ring at offset 2, never removed. |
| hit target | the density hit channel | 40px at the default rung; the 2xs pro-tool rung lands at exactly the WCAG 2.5.8 24px minimum (measured). |
| prefers-reduced-motion | supported | Drops the press transitions and effect loops; freezes the loading spinner on its first frame. |