Take colour off the overshooting spatial spring

Four components animated background-color and color on ease-spatial-fast, whose
fast variant peaks at 1.094 — the one thing tokens/motion.css says a colour must
never do. Tailwind cannot emit two springs on one element, so a small unlayered
file gives shape and size the spatial spring and colour and elevation the
effects one in a single property list. Plan step 18, actions.md ACT-19.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold
2026-09-14 05:59:30 +02:00
co-authored by Claude Opus 5
parent d560e7449c
commit 597656c88a
6 changed files with 54 additions and 8 deletions
+40
View File
@@ -0,0 +1,40 @@
/*
* The transitions an action runs between its states — buttons, connected segments, menu items,
* the FAB menu's trigger.
*
* M3 has two spring styles and they are not interchangeable: spatial moves things (position,
* size, rotation, rounded corners) and overshoots by 9% on the way, effects changes how things
* look (colour, opacity, elevation) and is critically damped, because a colour must never
* overshoot — tokens/motion.css says so in the library's own words. An element whose corners and
* colours both change on a press therefore needs two springs at once, which Tailwind cannot write
* (`duration-*` and `ease-*` each emit one value); CSS can, by giving `transition-property` a
* list and matching it position for position.
*
* `state-transition-fast` is the press/hover/select transition of a control the finger lands on:
* shape and size on the fast spatial spring, colour and elevation on the fast effects spring.
* `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB
* turning into a close button.
*
* Unlayered, as the rest of the package's component CSS, so a `transition-*` utility on the call
* site cannot half-undo one.
*/
@utility state-transition-fast {
transition-property: border-radius, padding, margin, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
}
@utility state-transition-default {
transition-property: border-radius, padding, margin, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration),
var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-effects-default-duration);
transition-timing-function:
var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default),
var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default);
}
+1
View File
@@ -20,6 +20,7 @@
@import './tokens/font.css';
@import './tokens/theme.css';
@import './tokens/state.css';
@import './components/actions.css';
@import './components/groups.css';
@import './components/list.css';
@import './components/field.css';
+4 -2
View File
@@ -9,7 +9,9 @@
`size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px,
`xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the
default) or `square`; either one squares off towards a smaller corner while pressed, on the
fast spatial spring.
fast spatial spring, while the colours cross on the fast effects spring beside it
(`state-transition-fast`, resources/css/components/actions.css: a colour must never
overshoot).
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
@@ -164,7 +166,7 @@
$classes = [
'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'state-transition-fast',
$dimensions,
// A composite component (a split button's halves) passes the corners its place needs.
$corners ?? $corner.' '.$pressed[$size],
@@ -14,7 +14,8 @@
Livewire, so it stays open through a render of the component around it.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
above the close button. --}}
above the close button. The FAB's corner and colours morph on the two default springs at once
(`state-transition-default`, resources/css/components/actions.css). --}}
@props([
'icon' => 'add',
@@ -46,7 +47,7 @@
@if ($label) aria-label="{{ $label }}" @endif
@class([
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
'state-transition-default aria-expanded:rounded-corner-full',
$colours,
])
>
+3 -2
View File
@@ -12,7 +12,8 @@
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
`inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a
segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells
you never to reduce.
you never to reduce. Corners move on the spatial spring and colours on the effects one
(`state-transition-fast`).
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
@@ -79,7 +80,7 @@
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'state-transition-fast',
'flex-1' => ! $inline,
$small ? 'touch-target min-w-12' : 'min-w-0',
$segment,
@@ -18,7 +18,8 @@
disabled.
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
12px at the ends of the list. --}}
12px at the ends of the list, the corner on the spatial spring and the colour on the effects
one (`state-transition-fast`). --}}
@props([
'label' => null,
@@ -45,7 +46,7 @@
->class([
'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none',
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'state-transition-fast',
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
'py-2' => filled($description),
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,