Motion
Motion on JSON Resume is quick and quiet. It confirms that something happened (a color shifts on hover, a dialog fades in) and never performs for its own sake. Nothing bounces, nothing slides across the page.
Durations
Four steps. base (250ms) is the default for state changes, matching jsonresume.org's hover transitions.
Show code
'use client';
import { Button, Stack } from '@jsonresume/ui';
import { motion, vars } from '@jsonresume/ui/tokens';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useState } from 'react';
import * as styles from './durations.css';
const durations = Object.keys(motion.duration) as Array<
keyof typeof motion.duration
>;
// Each row sets the duration variable; the stylesheet does the rest.
export default function MotionDurations() {
const [moved, setMoved] = useState(false);
return (
<Stack gap={4} data-moved={moved}>
<Button
variant="primary"
onClick={() => setMoved((m) => !m)}
className={styles.start}
>
{moved ? 'Reset' : 'Play'}
</Button>
{durations.map((name) => (
<div key={name} className={styles.row}>
<span className={styles.label}>
duration.{name} · {motion.duration[name]}
</span>
<div className={styles.track}>
<div
className={styles.box}
style={assignInlineVars({
[styles.duration]: vars.motion.duration[name],
})}
/>
</div>
</div>
))}
</Stack>
);
}import { createVar, style } from '@vanilla-extract/css';
import { vars } from '@jsonresume/ui/tokens';
/** Set per row with assignInlineVars. */
export const duration = createVar();
export const row = style({
display: 'flex',
alignItems: 'center',
gap: vars.space[4],
});
export const label = style({
flexShrink: 0,
width: '12rem',
whiteSpace: 'nowrap',
fontFamily: vars.font.family.mono,
fontSize: vars.font.size.xs,
color: vars.color.text.muted,
});
export const track = style({ position: 'relative', flex: 1, height: '2rem' });
export const box = style({
position: 'absolute',
insetBlock: 0,
width: '2rem',
borderRadius: vars.radius.sm,
background: vars.color.action.primary,
transition: `transform ${duration} ${vars.motion.easing.standard}`,
selectors: {
'[data-moved="true"] &': {
transform: 'translateX(calc(min(24rem, 60vw)))',
},
},
});
export const start = style({ alignSelf: 'flex-start' });| Token | Value | Use for |
|---|---|---|
| motion.duration.instant | 0ms | State that must not animate (toggling a selection highlight). |
| motion.duration.fast | 100ms | Small, local feedback: tooltips, focus, press states. |
| motion.duration.base | 250ms | Hover colors, borders, overlays, panels: the default. |
| motion.duration.slow | 400ms | Large surfaces entering, e.g. a full-screen sheet. Rare. |
Easing
standard for things that change in place, out for things that arrive. Linear looks mechanical, so keep it for spinners.
easing.standard · Things that move or change state
easing.out · Things that enter the screen
linear (avoid) · Only spinners and progress
Show code
'use client';
import { Button, Stack, Text } from '@jsonresume/ui';
import { vars } from '@jsonresume/ui/tokens';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { useState } from 'react';
import * as styles from './easings.css';
const curves = [
{
label: 'easing.standard',
value: vars.motion.easing.standard,
use: 'Things that move or change state',
},
{
label: 'easing.out',
value: vars.motion.easing.out,
use: 'Things that enter the screen',
},
{
label: 'linear (avoid)',
value: 'linear',
use: 'Only spinners and progress',
},
];
export default function MotionEasings() {
const [moved, setMoved] = useState(false);
return (
<Stack gap={4} data-moved={moved}>
<Button
variant="primary"
onClick={() => setMoved((m) => !m)}
className={styles.start}
>
{moved ? 'Reset' : 'Compare'}
</Button>
{curves.map((curve) => (
<Stack key={curve.label} gap={1}>
<Text size="xs" tone="muted">
<strong>{curve.label}</strong> · {curve.use}
</Text>
<div className={styles.track}>
<div
className={styles.box}
style={assignInlineVars({ [styles.easing]: curve.value })}
/>
</div>
</Stack>
))}
</Stack>
);
}import { createVar, style } from '@vanilla-extract/css';
import { vars } from '@jsonresume/ui/tokens';
/** Set per row with assignInlineVars. */
export const easing = createVar();
export const track = style({
position: 'relative',
height: '1.5rem',
background: vars.color.surface.subtle,
});
export const box = style({
position: 'absolute',
insetBlock: 0,
width: '1.5rem',
background: vars.color.action.ink,
// A slow duration so the curve is visible; real UI uses base.
transition: `transform 900ms ${easing}`,
selectors: {
'[data-moved="true"] &': {
transform: 'translateX(calc(min(30rem, 70vw)))',
},
},
});
export const start = style({ alignSelf: 'flex-start' });| motion.easing.standard | cubic-bezier(0.2, 0, 0, 1) |
| motion.easing.out | cubic-bezier(0, 0, 0.2, 1) |
Hover and focus
Interactive elements change color and border over base. Focus rings appear instantly: they are never animated.
Interactive card
The border darkens on hover.
Show code
import {
Button,
Card,
CardContent,
CardTitle,
Link,
Stack,
Text,
} from '@jsonresume/ui';
import * as styles from './interaction.css';
// Hover and focus changes use duration.base (250ms) on color and border
// only. Hover these, then Tab through them.
export default function MotionInteraction() {
return (
<Stack direction="row" gap={6} align="center" wrap>
<Button>Hover me</Button>
<Button variant="primary">And me</Button>
<Link href="#motion">A link</Link>
<Card interactive className={styles.root}>
<CardContent>
<CardTitle>Interactive card</CardTitle>
<Text size="xs" tone="muted">
The border darkens on hover.
</Text>
</CardContent>
</Card>
</Stack>
);
}import { style } from '@vanilla-extract/css';
export const root = style({ width: '12rem' });Entering and leaving
Fade with a short rise (8px) using easing.out. Animate only opacity and transform, so the page never reflows mid-animation.
Saved
Show code
'use client';
import { Button, Callout, Stack } from '@jsonresume/ui';
import { useState } from 'react';
import * as styles from './enter-exit.css';
export default function MotionEnterExit() {
const [open, setOpen] = useState(false);
return (
<Stack gap={4}>
<Button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls="saved-note"
>
{open ? 'Hide note' : 'Show note'}
</Button>
<div id="saved-note" className={styles.note} data-open={open}>
<Callout tone="success" title="Saved">
Your resume was published to the registry.
</Callout>
</div>
</Stack>
);
}import { style } from '@vanilla-extract/css';
import { vars } from '@jsonresume/ui/tokens';
const { duration, easing } = vars.motion;
// Enter with easing.out (fast start, gentle stop): fade and rise 8px.
// Only opacity and transform change, so nothing reflows.
export const note = style({
opacity: 0,
transform: 'translateY(8px)',
visibility: 'hidden',
transition: `opacity ${duration.base} ${easing.out}, transform ${duration.base} ${easing.out}, visibility ${duration.base}`,
selectors: {
'&[data-open="true"]': {
opacity: 1,
transform: 'none',
visibility: 'visible',
},
},
});Motion in components
What the components already do, so you rarely need to add motion yourself.
Show code
'use client';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
Stack,
Tooltip,
} from '@jsonresume/ui';
// Motion built into components: the dialog overlay fades and the panel
// rises (base, easing.out); tooltips fade (fast); the accordion chevron
// rotates.
export default function MotionInComponents() {
return (
<Stack gap={6}>
<Stack direction="row" gap={3} wrap>
<Dialog>
<DialogTrigger asChild>
<Button>Open dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Fade and rise</DialogTitle>
<DialogDescription>
The overlay fades in while the panel rises 8px.
</DialogDescription>
</DialogContent>
</Dialog>
<Tooltip content="Tooltips fade in over 100ms">
<Button variant="ghost">Hover for a tooltip</Button>
</Tooltip>
</Stack>
<Accordion type="single" collapsible>
<AccordionItem value="a">
<AccordionTrigger>The chevron rotates</AccordionTrigger>
<AccordionContent>
Disclosure content appears without sliding, so text never jumps.
</AccordionContent>
</AccordionItem>
</Accordion>
</Stack>
);
}Guidelines
Do
- Use base for nearly everything; reach for fast only for tiny feedback.
- Animate opacity, transform and color.
- Make motion explain cause and effect: the thing you clicked is the thing that changes.
Don’t
- Animate width, height, margins or top/left: they reflow the page.
- Bounce, spring, or overshoot.
- Animate focus rings or text.
- Run anything longer than slow (400ms) in response to a click.