Skip to main content
Each recipe encodes the motion decisions one interface pattern needs — which direction it comes from, what its transform origin is, how much shorter the exit is — and nothing else.
Recipes provide state selectors, transition and animation behaviour, timing, easing, transform origin where the behaviour requires it, and reduced-motion handling.Recipes do not provide colors, typography, dimensions, spacing, shadows, component structure beyond the minimum the movement requires, framework components, or application state.
They read the same state contract as tm-presence-*, and an element with no recognised state is treated as open.
180ms, scale from 0.96, and a transform origin taken from data-side. A menu should look like it comes out of its trigger, not out of its own middle. The names follow the convention Radix UI, Base UI and Floating UI already emit.
Alignment is deliberately not mapped. transform-origin takes no logical keywords, so a data-align rule would hard-code left or right and break in right-to-left contexts. Positioning libraries already publish a resolved origin — pass it straight through with style="--tm-origin: var(--radix-popper-transform-origin)".
For a native [popover], use tm-native-popover instead: the browser handles the top layer and light dismiss.

Dialog

Both halves move as one unit, so they share a duration (240ms) and an easing. Put the state attribute on both. For a <dialog> element use tm-native-dialog, which gets the backdrop and the top layer for free.

Toast

260ms, arriving from the edge it is anchored to. Toasts appear without being asked for, so the travel is short and the exit is quick. role="status" is yours to add: motion is not an announcement.

Tooltip

The fastest thing in the library: 120ms, a third of the usual travel, and a 60ms exit. A tooltip that takes 200ms to appear feels broken, because the pointer is already there. data-side sets the transform origin and the retreat direction — a tooltip above its trigger retreats upward, one below it downward.

Accordion

Child contract: exactly one element child, which TailMotion clips. Text nodes and multiple children will not animate correctly. Wrap your content in a single <div>.
The 0fr1fr grid row is the one height animation CSS can express without measuring anything, so this needs no JavaScript and no fixed height. Support is Chrome 107, Safari 16, Firefox 66. Older browsers show and hide the panel without the height tween — open content is never hidden. For a <details> element use tm-native-disclosure.

Tabs

Two independent pieces, because they have different requirements. The panel cross-fade needs no measurement:
The indicator does need measurement, and that stays with your component:
TailMotion animates translate and inline-size between whatever two values you give it. It never reads the DOM — see the zero-runtime boundary.

Loading to success

Child contract: one element per state, in any order, each carrying its state’s class — tm-feedback-idle, tm-feedback-loading, tm-feedback-success, and optionally tm-feedback-error.
All labels stay mounted and share one grid cell, so the button never resizes mid-transition and the outgoing label animates out as cleanly as the incoming one animates in. The state comes from data-state="idle" | "loading" | "success" | "error"; a button with no data-state shows the idle label. Success is the only state in the library that arrives with a spring, because it is the one moment in a form worth celebrating. --tm-overshoot: 0, or a calm profile, removes it.

Hold to confirm

A progress fill sweeps across the button while the pointer is held, and retreats in 160ms the moment it is released. Three things worth knowing:
  • Linear easing is correct here and almost nowhere else. The fill is a clock, and any other curve would misreport how much time is left.
  • The fill inherits the button’s own color through --tm-color at 18% opacity. TailMotion picks no red. overflow and isolation are set because the fill has to be clipped to the button’s box; your radius, padding and color stay yours.
  • It keeps its full duration under reduced motion, because it is a readout of elapsed time rather than decoration. Collapsing it to 1ms would confirm a destructive action the instant the button was touched.
Pair this with a key handler that sets data-state="holding" on keydown and removes it on keyup, or the interaction is pointer-only and inaccessible.
The older tm-hold-delete still works and is unchanged. It chooses its own colors, padding and radius, which is exactly why tm-hold-confirm exists.

Recipe timing at a glance

All of these scale with the motion profile and accept the usual tm-duration-* and --tm-exit-duration overrides.