Detent web components
By Shane Logsdon

Buttons & actions

Morphing Button

Implemented

Dimension-preserving state morphing button with fluid icon transitions

detent-morphing-button

Port status
Catalog status
implemented
Origin
upstream
Phase
4
Wave
phase-4-motion-controls
Group
Buttons & actions

6.3 KB gzip for the class module · inlines the shared spring

Variants

Local demo; no request leaves the page. Click the primary button above the Variants heading to watch a simulated deployment: it moves to loading (spinner, aria-busy="true" and the button disabled), then to success after 1.2 s. Every state occupies exactly the same box — a hidden sizer reserves the widest label — and the label and icon cross-fade through one spring. A rapid sequence of status changes retargets that spring instead of stacking. Each activation emits detent-click with { button, shiftKey, ctrlKey, altKey, metaKey }; under prefers-reduced-motion: reduce the states change instantly and the press scale stays at 1.

No activation yet.

Recorded states

  • focus-visible recorded state; no setter
  • pressed recorded state; no setter
  • disabled recorded state; no setter
  • loading recorded state; no setter
  • error recorded state; no setter
  • completed recorded state; no setter
  • idle recorded state; no setter
  • success recorded state; no setter

Usage

Import

import '/components/morphing-button/define.js';

// or register it yourself
import { DetentMorphingButton } from '/components/morphing-button/morphing-button.js';

Attributes

  • disabled
  • error-text
  • idle-text
  • loading-text
  • status
  • success-text
  • variant

Properties

  • onClick

Slots

  • error-icon
  • idle-icon
  • success-icon

Events

No events are recorded for this component.

Example

Runnable example: site/examples/morphing-button.ts. The snippet below is generated from the catalogued attributes.

<detent-morphing-button
  disabled
  error-text="Failed"
  idle-text="Save Changes"
  loading-text="Saving..."
  status="idle"
  success-text="Saved"
  variant="primary"
></detent-morphing-button>

Source

The pinned upstream source for this row:

  • src/components/ui/MorphingButton.tsx sha256 db6a57f39976…

Native files

The native module and its artifacts:

  • src/components/morphing-button/morphing-button.ts
  • src/components/morphing-button/styles.css
  • src/components/morphing-button/define.ts
  • docs/components/morphing-button.md
  • site/examples/morphing-button.ts
  • tests/browser/morphing-button.spec.ts
  • evidence/components/morphing-button.md

API

Boolean attributes follow HTML presence semantics: present means true, absent means false, and disabled="false" still means disabled.

Attributes

Attributes
AttributePropertyTypeDefaultNotes
disabled———Recorded in the catalog as an attribute.
error-texterrorTextstring'Failed'Kebab-case attribute; camelCase property.
idle-textidleTextstring'Save Changes'Kebab-case attribute; camelCase property.
loading-textloadingTextstring'Saving...'Kebab-case attribute; camelCase property.
statusstatus'idle' | 'loading' | 'success' | 'error''idle'Kebab-case attribute; camelCase property.
success-textsuccessTextstring'Saved'Kebab-case attribute; camelCase property.
variantvariant'primary' | 'secondary' | 'danger' | 'ghost''primary'Kebab-case attribute; camelCase property.

Properties

Properties
PropertyTypeDefaultNotes
onClick——Recorded in the catalog without a top-level type; pinned from source when the row is ported.

Events

No custom events are recorded for this component.

Slots

Slots
SlotNotes
error-iconConsumer content; rendered through a slot, never re-created.
idle-iconConsumer content; rendered through a slot, never re-created.
success-iconConsumer content; rendered through a slot, never re-created.

CSS customisation

  • Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
  • `lucide-react` icon components replaced with local SVG assets (ISC notice retained).
  • React state/hooks replaced by element-owned state with pre-upgrade property preservation.
  • disabled is an attribute and a property: the row records the disabled state and the pinned source takes it from ButtonHTMLAttributes, so the state would otherwise be unreachable from HTML.
  • success and error keep the shared text weight so the box cannot re-measure (upstream used font-semibold); the tone colour carries the state.
  • A hidden sizer stacking the four labels reserves the widest width, which is what implements the recorded zero-layout-shift feature.
  • primary is the page inverse (text on background) and flips with the scheme; secondary, danger and ghost follow tokens; the boundary is a text/background mix for WCAG 1.4.11.
  • The morph is one spring progress (out, swap at the transparent point, in) instead of AnimatePresence mode=wait, so interruptions collapse intermediate labels.
  • Activation is the native composed click and the onClick property hook; the detent-click companion the row originally recorded was dropped on 2026-10-26 after the PC-6.1 review (a native event is never re-emitted under a custom name), and the row is corrected here.

What it does

An action button whose label and icon morph between four states — idle, loading, success and error — inside a box that never changes size.

Upstream behaviour
  • Zero layout shift architecture preserving natural bounding dimensions across states
  • Icon morphing with spring-based scale and translation transitions
  • Multiple design presets (Primary, Secondary, Danger, and Ghost)
  • Interactive spring tap physics (whileTap scale 0.97)
  • Complete disabled and busy ARIA state compatibility
Differences from upstream
  • Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
  • `lucide-react` icon components replaced with local SVG assets (ISC notice retained).
  • React state/hooks replaced by element-owned state with pre-upgrade property preservation.
  • disabled is an attribute and a property: the row records the disabled state and the pinned source takes it from ButtonHTMLAttributes, so the state would otherwise be unreachable from HTML.
  • success and error keep the shared text weight so the box cannot re-measure (upstream used font-semibold); the tone colour carries the state.
  • A hidden sizer stacking the four labels reserves the widest width, which is what implements the recorded zero-layout-shift feature.
  • primary is the page inverse (text on background) and flips with the scheme; secondary, danger and ghost follow tokens; the boundary is a text/background mix for WCAG 1.4.11.
  • The morph is one spring progress (out, swap at the transparent point, in) instead of AnimatePresence mode=wait, so interruptions collapse intermediate labels.
  • Activation is the native composed click and the onClick property hook; the detent-click companion the row originally recorded was dropped on 2026-10-26 after the PC-6.1 review (a native event is never re-emitted under a custom name), and the row is corrected here.
Previous: Morphing Blob Next: Morphing Dialog

Docs

Components

Project