Skip to content

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.

Every duration, side by side
duration.instant · 0ms
duration.fast · 100ms
duration.base · 250ms
duration.slow · 400ms
Show code
motion/durations.tsx
'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>
  );
}
motion/durations.css.ts
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' });
TokenValueUse for
motion.duration.instant0msState that must not animate (toggling a selection highlight).
motion.duration.fast100msSmall, local feedback: tooltips, focus, press states.
motion.duration.base250msHover colors, borders, overlays, panels: the default.
motion.duration.slow400msLarge 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.

Curves compared at a slow speed

easing.standard · Things that move or change state

easing.out · Things that enter the screen

linear (avoid) · Only spinners and progress

Show code
motion/easings.tsx
'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>
  );
}
motion/easings.css.ts
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.standardcubic-bezier(0.2, 0, 0, 1)
motion.easing.outcubic-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.

Hover, then Tab through
A link

Interactive card

The border darkens on hover.

Show code
motion/interaction.tsx
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>
  );
}
motion/interaction.css.ts
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.

Show and hide a confirmation

Saved

Your resume was published to the registry.
Show code
motion/enter-exit.tsx
'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>
  );
}
motion/enter-exit.css.ts
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.

Dialog, tooltip and accordion

Show code
motion/components.tsx
'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.