Material ripple state layer and theme reveal pulse
Desktop Material mirrors the Desktop Material v2.dc.html
prototype's two app-wide motion primitives:
- a ripple state layer that spawns at the press point inside every interactive control, scales, and fades; and
- a theme reveal pulse — a full-screen radial wash that radiates from the app-bar theme toggle corner on every theme change.
Both consume the global dmRipple / dmReveal
@keyframes declared in
app/styles/material/_motion.scss (pre-staged for this
feature) and are suppressed under reduced motion.
Behavior
Ripple
attachRipple(host, origin)inapp/src/ui/lib/ripple.tsappends a single<span class="md-ripple">to the host, sized tomax(width, height)and centred on the pointer (origin.clientX/Y, falling back to the host centre).- The shared
Buttoncomponent (app/src/ui/lib/button.tsx) calls it fromonMouseDown, so everyButtonin the app ripples for free — including preferences, dialogs, and toolbars — with no per-call-site wiring. - The span inherits
currentColor, so the state layer automatically tints to each control's--md-sys-color-on-*role (on-primary for filled/primary submit buttons, on-surface for tonal secondary buttons, on-error-container for destructive buttons). - The span is removed on
animationend, with a 700ms timeout fallback so it can never leak if the event is missed.
Theme reveal
AppTheme(app/src/ui/app-theme.tsx) mounts a transient<div class="theme-reveal-overlay">whenever the applied theme class actually flips, and removes it onanimationend(1000ms fallback).- The very first theme application (on mount) does not pulse; only subsequent flips do.
- The overlay is a fixed full-screen radial gradient with
transform-origin: 78% 8%so the wash emanates from the app-bar toggle corner, matching the prototype.
Styling
app/styles/ui/_ripple.scss styles the two transient
elements and gives .button-component a positioning context.
Clipping to the pill silhouette is free: .button-component
already sets overflow: hidden via the ellipsis
mixin, and overflow: hidden respects the button's
border-radius, so the scaling circle never escapes rounded
corners. The partial is registered in
app/styles/_ui.scss.
Reduced motion
prefersReducedMotion() returns true when
either the OS
prefers-reduced-motion: reduce media query matches
or the app's own data-dm-motion="reduced"
appearance preference is set on <body>. In that
case:
- no ripple span is created; and
- no reveal overlay is mounted.
Because AppTheme.applyAppearance() writes
data-dm-motion before the reveal is evaluated, switching
the app to reduced motion suppresses the pulse on the same interaction.
As a defense in depth, _ripple.scss and the global rules in
_material-shell.scss also force any such animation to an
instant fade.
Failure modes
- Missing / disabled host —
attachRipplereturnsnullfor a null host or a natively disabled<button>;Buttonadditionally skips the ripple when itsdisabledprop is set (it models disabled viaaria-disabled). - No layout box — with a zero-size host (e.g. detached element) the span is still created and cleaned up harmlessly.
- Missed
animationend— the timeout fallbacks guarantee removal.
Scope boundary
Controls that do not render through the shared Button —
notably the toolbar
ToolbarButton/ToolbarDropdown family used by
the top-level menu bar — do not yet ripple. Wiring
attachRipple into those toolbar controls is a follow-up
owned by the toolbar surface. That band is now behind Settings →
Appearance → Show the classic toolbar (shipped on) since the MD3 shell landed, which narrows how much of
the interface the gap covers but does not close it — the follow-up still
stands for as long as the band ships.
The shell added a second family in the same position:
Md3IconButton, Md3TonalButton and
Md3GhostButton in
app/src/ui/md3/md3-primitives.tsx render their own
<button> rather than the shared Button,
and none of them calls attachRipple today. They carry the
contract's own :hover and :focus-visible
treatments, so they are not unstyled — but they do not ripple, and
wiring attachRipple into the three MD3 primitives is the
same follow-up, now owned by the shell surface.
Verification
app/test/unit/ripple-motion-test.tsx covers:
- ripple span lifecycle (created at the press point on
mousedown, removed afteranimationend); - pointer-centred and centre-fallback placement maths;
- suppression for a disabled button, the
data-dm-motion="reduced"preference, and the systemprefers-reduced-motionquery; - reveal overlay mount on theme flip (but not on mount), removal on
animationend, suppression under reduced motion, and cleanup on unmount.