Motion
Motion tokens set how long a transition takes and how it speeds up and slows down. Use them for every transition and animation so interface changes feel consistent and can be switched off for people who ask for less motion.
All seven motion tokens are code-only (src/code/motion.json): Figma variables can't hold durations or easing curves, so Figma has no motion spec. Beta because the values are a proposal drawn from existing Nuton code (token-architecture §2, Motion) awaiting DS Owner sign-off, the planned read-only “Code tokens” reference page in Figma doesn't exist yet, and components only use the fast duration and the standard curve so far.
Overview
Motion shows cause and effect: a menu grows from its trigger, a selected option fills in, a new lesson slides into a list. Nuton keeps it short and quiet, so it explains a change without slowing anyone down.
Pick a duration and an easing curve, then play the transition. The dot shows the curve's shape over time; the lesson card shows the same timing on real content.
- Photosynthesis
import { tokens } from '@nuton/tokens'
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const timing: KeyframeAnimationOptions = {
duration: reduce ? 0 : parseFloat(String(tokens['duration-base'].resolved)),
easing: String(tokens['ease-standard'].resolved),
fill: 'both',
}
const lift = String(tokens['space-2'].resolved)
card.animate([{ opacity: 0, transform: `translateY(${lift})` }, { opacity: 1, transform: 'translateY(0)' }], timing)- Durations scale with the size of the change. Small state changes use
--nu-duration-fast; larger surfaces step up. - Easing gives motion its character.
--nu-ease-standardstarts quickly and settles gently, and it is the default. - Reduced motion is honoured everywhere: when it's on, the change still happens, just without movement.
Tokens
Generated from @nuton/tokens. Every value below is read from the package at build time.
Duration
| Preview | Token | Value | Source |
|---|---|---|---|
--nu-duration-instant | 100ms | Code-only | |
--nu-duration-fast | 160ms | Code-only | |
--nu-duration-base | 200ms | Code-only | |
--nu-duration-slow | 300ms | Code-only | |
--nu-duration-panel | 360ms | Code-only | |
--nu-duration-slower | 500ms | Code-only |
Each preview uses its duration with --nu-ease-standard. Previews stop when reduced motion is on.
| Token | Use it for | Used by |
|---|---|---|
--nu-duration-instant | Press feedback, and small overlays leaving: a tooltip, a menu or a popover closing. Exits are quicker than enters so the interface never makes people wait. | Tooltip, Popover, Menu, Audio player |
--nu-duration-fast | Hover, focus and selection changes; menus and popovers opening; toggles. | Button, Link button, Button group, inputs, menus, combobox, command menu |
--nu-duration-base | Small elements entering or leaving: a toast, an inline message, a card added to a list; a scrim fading; a sheet or dialog leaving. | Learner sheets and dialogs, the mobile app's scrim, Audio player |
--nu-duration-slow | Sheets and dialogs entering; larger surfaces moving: the tutor panel sheet or navigation drawer at smaller breakpoints (D23). | Learner sheets and dialogs, the mobile app's sheets, Audio player |
--nu-duration-panel | Large panels and drawers growing or settling: the tutor panel, an expanding player. | Learner panels, Ask Nuri |
--nu-duration-slower | Large reveals that need time to read, and one-time celebrations. Rare in the product. | The mobile app's podcast player, celebrations |
Easing
| Preview | Token | Value | Source |
|---|---|---|---|
--nu-ease-standard | cubic-bezier(0.16, 1, 0.3, 1) | Code-only | |
--nu-ease-emphasized | cubic-bezier(0.32, 0.72, 0, 1) | Code-only | |
--nu-ease-linear | cubic-bezier(0, 0, 1, 1) | Code-only | |
--nu-ease-exit | cubic-bezier(0.4, 0, 1, 1) | Code-only |
Each preview uses its curve over --nu-duration-slower, so the shape is easy to see. --nu-ease-linear is emitted as a cubic-bezier that behaves exactly like CSS linear.
| Token | Use it for | Used by |
|---|---|---|
--nu-ease-standard | The default. Fast start, long gentle stop: right for state changes and for anything entering the screen. | Every component transition |
--nu-ease-emphasized | Larger, more expressive moves such as a sheet or drawer sliding in, where the motion should feel deliberate. | No component yet |
--nu-ease-exit | Anything leaving: it starts moving at once and speeds away, so a closing sheet or dialog feels responsive. Pair it with a shorter duration than the enter. | Learner sheets and dialogs |
--nu-ease-linear | Continuous or looping motion only: loading shimmer, progress that tracks real time. | No component yet (the Button spinner uses a hard-coded linear loop) |
Source: packages/tokens/src/code/motion.json, flagged nuton.codeOnly. Values were collected from nuton-teach (fade-in and slide-up) and the marketing site's most-used curves; a third marketing curve close to --nu-ease-standard is to be merged into it.
Usage
Always pair a duration token with an easing token, and turn the motion off under prefers-reduced-motion.
/* After: import '@nuton/tokens/tokens.css' */
.lesson-card {
transition:
background-color var(--nu-duration-fast) var(--nu-ease-standard),
box-shadow var(--nu-duration-fast) var(--nu-ease-standard);
}
.tutor-sheet {
transition: transform var(--nu-duration-slow) var(--nu-ease-emphasized);
}
@media (prefers-reduced-motion: reduce) {
.lesson-card, .tutor-sheet { transition: none; }
}/* app.css */
@import 'tailwindcss';
@import '@nuton/tokens/tokens.css';
@import '@nuton/tokens/tailwind-v4.css'; /* adds ease-standard, ease-emphasized, ease-linear */
// The v4 theme has no duration names: pass the token as an arbitrary value.
<div className="transition-colors ease-standard duration-(--nu-duration-fast) motion-reduce:transition-none">
Photosynthesis
</div>// tailwind.config.js
module.exports = { presets: [require('@nuton/tokens/tailwind-v3-preset')] }
// Adds duration-fast, duration-base, duration-slow, duration-slower
// and ease-standard, ease-emphasized, ease-linear.
<div className="transition-colors duration-fast ease-standard motion-reduce:transition-none">
Photosynthesis
</div>import { tokens } from '@nuton/tokens'
// For the Web Animations API, read the resolved values; check reduced motion first.
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
panel.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: reduce ? 0 : parseFloat(String(tokens['duration-base'].resolved)),
easing: String(tokens['ease-standard'].resolved),
})transition-colors and motion-reduce: are Tailwind's own utilities. The Nuton names come from tailwind-v4-theme.css (easing only) and the v3 preset's transitionDuration and transitionTimingFunction.
Choosing a token
- Start with
--nu-duration-fastand--nu-ease-standard. Step up only when the element is bigger or travels further. - Animate
opacity,transformand colours. Avoid animating size or layout, which is slower and makes content jump. - Keep interface feedback at or below
--nu-duration-base, so it never delays the next click. - Use
--nu-ease-linearonly for loops and progress; it looks mechanical on things that start and stop. - Never hard-code a duration or curve. If no token fits, raise it with the DS Owner.
In use
Every @nuton/ui transition uses --nu-duration-fast with --nu-ease-standard. Hover, focus and open these to see it.
Button hover
Background, border and focus shadow change together. Point at a button, or tab to it.
/* Button.css */
.nu-button {
transition:
background-color var(--nu-duration-fast) var(--nu-ease-standard),
border-color var(--nu-duration-fast) var(--nu-ease-standard),
box-shadow var(--nu-duration-fast) var(--nu-ease-standard);
}
<Button appearance="stroke">Save draft</Button>
<Button trailingIcon={<RiArrowRightLine />}>Publish lesson</Button>Menu opening
The menu fades in and scales up slightly from its trigger. Inside it, the switch thumb slides with the same timing.
/* Menu.css */
.nu-menu-content { animation: nu-menu-in var(--nu-duration-fast) var(--nu-ease-standard); }
@keyframes nu-menu-in { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" appearance="stroke" leadingIcon={<RiMore2Line />}>Lesson options</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Photosynthesis</DropdownMenuLabel>
<DropdownMenuItem leading={<RiEditLine />} onSelect={rename}>Rename</DropdownMenuItem>
<DropdownMenuItem leading={<RiFileCopyLine />} onSelect={duplicate}>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSwitchItem leading={<RiBellLine />} checked={notify} onCheckedChange={setNotify}>
Notify students
</DropdownMenuSwitchItem>
</DropdownMenuContent>
</DropdownMenu>Selection and focus
Selecting a range fills the item in; focusing a field changes its border and shadow. Both use the fast duration.
Showing study time for this week.
For example, Biology 101.
<ButtonGroup size="xs" aria-label="Progress range">
<ButtonGroupItem pressed={range === 'Day'} onClick={() => setRange('Day')}>Day</ButtonGroupItem>
<ButtonGroupItem pressed={range === 'Week'} onClick={() => setRange('Week')}>Week</ButtonGroupItem>
<ButtonGroupItem pressed={range === 'Month'} onClick={() => setRange('Month')}>Month</ButtonGroupItem>
</ButtonGroup>
<Field label="Space name" hint="For example, Biology 101.">
<Input size="sm" />
</Field>Motion in each component
| Component | What moves | Tokens | With reduced motion |
|---|---|---|---|
| Button | Background, border, focus shadow | --nu-duration-fast · --nu-ease-standard | No transition. The loading spinner slows down instead of stopping, so loading stays visible. |
| Link button | Text colour | --nu-duration-fast · --nu-ease-standard | No transition |
| Button group | Item background | --nu-duration-fast · --nu-ease-standard | No transition |
| Input, Textarea, Digit input | Background, border, focus shadow | --nu-duration-fast · --nu-ease-standard | No transition |
| Dropdown menu | Menu fades and scales in; switch track and thumb | --nu-duration-fast · --nu-ease-standard | No animation or transition |
| Combobox | Chevron rotates | --nu-duration-fast · --nu-ease-standard | No transition |
| Command menu | Dialog and overlay fade in | --nu-duration-fast · --nu-ease-standard | No animation |
Gap: the Button spinner doesn't use motion tokens
.nu-button__spinner loops with a hard-coded duration and a plain linear curve, and its reduced-motion duration is also hard-coded. A looping duration token (or --nu-ease-linear plus a documented loop length) is needed before motion can be Stable. Not yet recorded with a decision ID.
Accessibility
Motion must never be the only way a change is shown, and it must stop when people ask for less of it.
Reduced motion
Every @nuton/ui component removes its transitions and animations under prefers-reduced-motion: reduce. The state still changes instantly. The only exception is the Button spinner, which slows down rather than stopping so a loading button still looks busy.
This docs site also shortens every transition and animation to almost nothing under the same setting. Your product needs its own rule.
Animation from interactions
Component motion is small (opacity, a slight scale, colour) and short: the longest token is --nu-duration-slower (500ms). Large movement such as sheets and drawers must also be removed under reduced motion (WCAG 2.3.3).
Flashing
No token or component flashes. Don't build anything that flashes more than three times a second (WCAG 2.3.1).
Moving content
Anything that moves on its own for more than five seconds needs a way to pause it (WCAG 2.2.2). Loading indicators stop when loading ends.
The looping previews on this page are paused by default and play only while you hover or focus their row or card, so nothing moves unless you ask for it. They don't move at all under reduced motion.
You must: add a prefers-reduced-motion: reduce rule for every transition you write; keep the end state visible without the animation; check reduced motion before starting Web Animations. The planned visual regression suite snapshots every component with reduced motion on (quality strategy).
Guidelines and related
Use motion for
- Feedback on hover, focus, press and selection.
- Showing where something came from: a menu from its button, a sheet from the edge.
- Easing an element in or out so the layout change is easy to follow.
Don't use motion for
- Decoration that doesn't explain a change.
- The only signal of a state change. Pair it with colour, text or an attribute such as
aria-pressed. - Delaying people: nothing should wait for an animation to finish before it can be used.
Do and don't
transition: all 250ms ease-in-out;