Easing & transitions
StableFour motion curves, a soft cross-fade for view changes, a reduced-motion hook and disclosures that ease open.
Motion here is quick to answer and slow to arrive. ease-lux is a strong ease-out for hovers and presses, ease-lux-expo is for long arrivals, ease-lux-inout for things moving while on screen, and ease-lux-spring for one-off landings. withViewTransition cross-fades page and theme changes, useMotionOK gates motion CSS can’t reach, and lux-details eases a <details> open.
The four curves
import { useState } from 'react'import { Button } from '@rchursin/product/ui'
/** * The four curves as Tailwind utilities (ease-lux, ease-lux-expo, ease-lux-inout, * ease-lux-spring), each at the duration it is made for. Press Play to send the squares across * and back. With reduced motion (motion-reduce:transition-none) they jump. */const curves = [ { name: 'ease-lux', ms: 450, use: 'Hovers, presses, menus', className: 'ease-lux duration-450' }, { name: 'ease-lux-expo', ms: 1100, use: 'Arrivals and count-ups', className: 'ease-lux-expo duration-1100' }, { name: 'ease-lux-inout', ms: 600, use: 'Moving while on screen', className: 'ease-lux-inout duration-600' }, { name: 'ease-lux-spring', ms: 700, use: 'One-off landings', className: 'ease-lux-spring duration-700' },]
export default function EasingCurves() { const [end, setEnd] = useState(false) return ( <div className="flex w-full max-w-xl flex-col gap-6"> <ul className="flex flex-col gap-4">Usage
import { useMotionOK, withViewTransition } from '@rchursin/components/motion'<div className="transition-opacity duration-450 ease-lux motion-reduce:transition-none">…</div>
<button onClick={() => withViewTransition(() => setView('grid'))}>Grid view</button>Examples
Cross-fade a change
Change the sort: the old order fades into the new one. Browsers without View Transitions, and reduced motion, switch at once.
import { useState } from 'react'import { withViewTransition } from '@rchursin/components/motion'import { Segmented } from '@rchursin/product/ui'
/** * withViewTransition wraps a state change in the browser's View Transition: the old view * cross-fades into the new one in 450ms. Browsers without it, and reduced motion, just switch. */type Sort = 'runs' | 'name'
const skills = [ { name: 'test-fixer', runs: 214 }, { name: 'release-notes', runs: 38 }, { name: 'pr-review', runs: 121 }, { name: 'db-migrate', runs: 0 },]
export default function EasingViewTransition() { const [sort, setSort] = useState<Sort>('runs') const sorted = [...skills].sort((a, b) => (sort === 'runs' ? b.runs - a.runs : a.name.localeCompare(b.name)))Disclosure
Native <details> with .lux-details and a shared name, so one answer is open at a time.
import { useId } from 'react'
/** * .lux-details eases a native <details> open (opacity, and height where the browser can animate * to auto). Sharing a name makes it an exclusive group: opening one closes the others. */const faqs = [ { q: 'Do I need an animation library?', a: 'No. Every effect in the finishing layer is CSS, plus a few small hooks for scroll and pointer position.' }, { q: 'What happens with reduced motion?', a: 'Movement stops. Colour and opacity changes stay, and content that would arrive is simply there.' }, { q: 'Does it work before JavaScript loads?', a: 'Yes. Content is visible at rest; arrivals only hide it once the script has armed them.' },]
export default function EasingDisclosure() { const group = useId() return ( <div className="flex w-full max-w-lg flex-col"> <hr className="lux-rule" /> {faqs.map((f, i) => ( <div key={f.q}> <details name={group} open={i === 0} className="lux-details group"> <summary className="flex cursor-pointer list-none items-center justify-between gap-6 rounded-md py-4 text-[14px] font-medium text-fg focus-ring [&::-webkit-details-marker]:hidden"> {f.q} <span aria-hidden="true" className="relative size-3 shrink-0"> <span className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-current" /> <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-current transition-[rotate,opacity] duration-500 ease-lux group-open:rotate-90 group-open:opacity-0" /> </span> </summary> <p className="pb-5 text-[13.5px]/6 text-pretty text-fg-2">{f.a}</p> </details> <hr className="lux-rule" /> </div> ))} </div> )}Motion CSS can’t reach
A smooth scroll is set in script, not CSS, so it asks useMotionOK() first and jumps when motion is reduced.
import { useRef } from 'react'import { useMotionOK } from '@rchursin/components/motion'import { Button } from '@rchursin/product/ui'
/** * useMotionOK() is for motion that CSS can't gate, like a smooth scroll. It is false on the * server and on the first render, then follows the visitor's setting (and its changes). */const versions = ['1.0', '1.1', '1.2', '1.3', '1.4', '1.5', '2.0', '2.1', '2.2', '2.3', '2.4']
export default function EasingMotionOK() { const motionOK = useMotionOK() const list = useRef<HTMLOListElement>(null)
function jumpToLatest() { list.current?.lastElementChild?.scrollIntoView({ behavior: motionOK ? 'smooth' : 'auto', block: 'nearest', inline: 'nearest' }) }
return ( <div className="flex w-full max-w-md flex-col gap-4"> <ol ref={list} tabIndex={0} aria-label="Releases" className="rc-scroll flex gap-2 overflow-x-auto rounded-xl pb-2 focus-ring"> {versions.map((v, i) => ( <li key={v} className="lux-card lux-card-flat flex w-20 shrink-0 flex-col gap-1 rounded-xl bg-raised px-3 py-2.5 text-[12px]"> <span className="font-mono text-fg">v{v}</span> <span className="text-fg-3">{i === versions.length - 1 ? 'Latest' : 'Release'}</span> </li> ))} </ol> <div className="flex flex-wrap items-center gap-3"> <Button variant="secondary" size="sm" iconRight="arrow-right" onClick={jumpToLatest}> Jump to latest </Button> <span className="text-[13px] text-fg-3">{motionOK ? 'Scrolls smoothly' : 'Jumps straight there'}</span> </div> </div> )}API
withViewTransition(update)
| Prop | Type | Default | Description |
|---|---|---|---|
updaterequired | () => void | – | A state change. Runs inside document.startViewTransition (flushed synchronously, so the new view is ready to fade to). Runs straight away when the browser has no View Transitions or motion is reduced. |
useMotionOK()
| Prop | Type | Default | Description |
|---|---|---|---|
returns | boolean | – | true when prefers-reduced-motion is no-preference. false on the server and on the first render; it updates when the setting changes. |
CSS
| Class or variable | What it does |
|---|---|
ease-lux | cubic-bezier(0.23, 1, 0.32, 1): a strong ease-out for hovers, presses and menus, 150 to 450ms. |
ease-lux-expo | cubic-bezier(0.16, 1, 0.3, 1): long and calm, for arrivals and count-ups, 900 to 1300ms. |
ease-lux-inout | cubic-bezier(0.77, 0, 0.175, 1): for things that move while already on screen, like a sliding tab indicator. |
ease-lux-spring | A linear() spring with a 1.5% overshoot, for one-off landings such as the verdict stamp, at 600 to 700ms. |
--ease-lux … | The same four curves as CSS variables, for your own transition and animation rules. |
--default-transition-duration | Every Tailwind transition-* utility defaults to 200ms on ease-lux. |
lux-details | On a <details>: the content fades in over 450ms and, where the browser can animate to an auto height, grows open over 500ms. Motion allowed only; otherwise it opens at once. |
::view-transition-old(root), ::view-transition-new(root) | The page cross-fade: 450ms on ease-lux. All view-transition animation is off with reduced motion. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Tab | Moves between the disclosure summaries. A closed answer is skipped. |
| EnterSpace | Opens or closes the focused disclosure. In a named group, opening one closes the one that was open. |
- A curve is only timing. Pair movement with motion-reduce:transition-none (or put it behind motion-safe:) so reduced-motion visitors get the end state at once.
- withViewTransition skips the cross-fade with reduced motion, and the stylesheet turns every view-transition animation off then as well.
- lux-details is a native <details> and <summary>: the browser handles Enter, Space and the expanded state for screen readers.
- useMotionOK is false until the page has loaded, so use it for behaviour, like smooth scrolling, not for markup that would flash.
Guidelines
Do
- Answer a click within 200ms; let arrivals take 600 to 1100ms.
- Use ease-lux for things that enter or answer, ease-lux-inout for things already on screen.
- Make exits faster than entrances.
Don’t
- Don’t use the spring for repeated or interactive motion; save it for one landing.
- Don’t run a view transition for a small change inside one component: it fades the whole page.
In use
Used on this site
Related
- Scroll arrivalsContent that fades and rises in as it scrolls into view, bars that grow, stamps that land and a frame that draws itself.
- Count upA number that counts up once, the first time it scrolls into view. Screen readers get the final value straight away.
- TabsSwitch between views of the same thing. A lit pill or a hairline glides to the selected tab; arrow keys move between tabs.
- FAQQuestions and answers on native <details>: one answer open at a time, with no JavaScript required.
Source
The component’s own files, exactly as they run on this site.
/* RChursin_ brand tokens (from the brand kit) for Tailwind CSS v4. Every token becomes a utility: bg-ink, text-paper, bg-graphite, text-fog, rounded-tile, shadow-lift, drop-shadow-lift … Fonts are loaded with next/font in src/styles/fonts.ts and wired up in globals.css.
Brand rules: black and white only — greys are for UI surfaces and text. The logo is one colour at a time, never with gradients, glows or effects.*/
@theme { /* Colour */ --color-ink: #0a0a0a; /* primary black: logo, text on light, dark backgrounds */ --color-paper: #ffffff; /* primary white: logo on dark, light backgrounds */ --color-graphite: #171716; /* raised dark surface: cards, sidebars */ --color-board: #1f1f1e; /* presentation / canvas background */ --color-fog: #a3a3a3; /* secondary text on dark (≥ 4.5:1 on ink) */ --color-stone: #737373; /* secondary text on light (≥ 4.5:1 on paper) */
/* Shape */