AZMARA PLATFORM
Components

Motion

CSS-first motion presets driven by tokens, a Collapse that never measures height, a count-up, a ViewTransition wrapper, useReducedMotion and an optional module for the motion library. Reduced motion is built in.

View as Markdown for coding assistants, or see llms.txt.

Principles

  • CSS first. Motion is a class or a data-* state, with its duration and easing from tokens (--motion-duration-*, --motion-ease-*). Changing a token or turning on reduced motion changes it everywhere.
  • Cheap properties only. Animations change transform, scale, opacity and clip-path; collapse animates grid-template-rows; the tick draws with stroke-dashoffset. Nothing animates width, height, margin or position, because those make the browser re-layout every frame. A test scans the CSS and every component and fails on a layout animation that is not on a documented exception list.
  • Reduced motion is the default behaviour, not an extra. Under prefers-reduced-motion: reduce the durations collapse to near zero, every slide and scale becomes a plain fade, staggers lose their delays, the shimmer stops and the tick appears at once.

The presets ship in @azmr/ui/styles.css. If you import only tokens.css, they are not included.

Presets

Preview theme
Preview density
Fade
Scale
Slide up
Slide down
Slide left
Slide right
<div className="flex flex-col items-start gap-4">
  <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
    Replay
  </Button>
  <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
    {tiles.map(([label, cls]) => (
      // The key restarts the animation each time Replay is pressed.
      <div
        key={`${label}-${run}`}
        className={`${cls} flex h-16 w-32 items-center justify-center rounded-md border border-border bg-card text-sm`}
      >
        {label}
      </div>
    ))}
  </div>
</div>
Open as text

Each preset is a pair of classes, enter and exit. enter eases out and runs on --motion-duration-base (200 ms); exit is shorter (--motion-duration-fast, 150 ms) and eases in, because people wait for things to appear but not to disappear.

PresetClasses
Fademotion-fade-in, motion-fade-out
Scalemotion-scale-in, motion-scale-out
Slide up, down, left, rightmotion-slide-up-in, motion-slide-up-out, and the same for down, left, right
Shimmermotion-shimmer

Use the classes directly, or take them from motionPresets (typed, so a typo is a compile error):

import { motionPresets } from "@azmr/ui/presets";

<div className={motionPresets.slideUp.enter}>…</div>

For Base UI parts (dialogs, menus, popovers) the library already animates with data-starting-style and data-ending-style on the same tokens; these presets are for your own elements.

Collapse

Preview theme
Preview density
<div className="flex max-w-md flex-col gap-3">
  <Button
    variant="outline"
    aria-expanded={open}
    aria-controls={id}
    onClick={() => setOpen((o) => !o)}
  >
    {open ? "Hide details" : "Show details"}
  </Button>
  <Collapse id={id} open={open}>
    <p className="text-sm text-muted-foreground">
      This panel animates its height by moving grid-template-rows between 0fr and 1fr, so no
      height is ever measured in JavaScript. While it is closed it is inert: its content cannot
      be focused or read.
    </p>
    <Button size="sm" className="mt-3">
      A focusable control
    </Button>
  </Collapse>
</div>
Open as text

Collapse shows and hides content by animating grid-template-rows between 0fr and 1fr, so no height is measured in JavaScript. It is a Server Component. The content stays mounted while closed (so it keeps its state) and is inert and aria-hidden, so it cannot be focused or read.

You own the trigger: give it aria-expanded and aria-controls pointing at the collapse's id, as in the demo. For a ready-made disclosure with all of that wired, use Collapsible or Accordion; they measure the panel through Base UI instead (a documented exception to the cheap-properties rule).

Prop

Type

Stagger

Preview theme
Preview density
  • Design tokens
  • Components
  • Accessibility
  • Motion
  • Docs
<div className="flex flex-col items-start gap-3">
  <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
    Replay
  </Button>
  <Stagger key={run} as="ul" className="flex flex-col gap-2 text-sm">
    {items.map((item) => (
      <li key={item} className="rounded-md border border-border bg-card px-3 py-2">
        {item}
      </li>
    ))}
  </Stagger>
</div>
Open as text

Stagger makes the children of a list enter one after another. Each child gets a --stagger-index, and the delay is the index times --motion-stagger-step (40 ms), capped at max (12) so a long list does not take seconds to appear. Under reduced motion the step is zero and they fade in together. It is a Server Component; only element children are staggered.

Prop

Type

Success check and shimmer

Preview theme
Preview density
Saved
<div className="flex flex-col items-start gap-6">
  <div className="flex items-center gap-3">
    <AnimatedCheck key={run} size={32} className="text-success" label="Saved" />
    <span className="text-sm">Saved</span>
    <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
      Replay
    </Button>
  </div>
  <div className="flex w-72 flex-col gap-2">
    <Skeleton shimmer className="h-4 w-full" />
    <Skeleton shimmer className="h-4 w-4/5" />
    <Skeleton shimmer className="h-16 w-full" />
  </div>
</div>
Open as text

AnimatedCheck is an SVG tick that draws itself in the current text colour (className="text-success"); pass label to make it an image with a name, otherwise it is decorative. Skeleton shimmer sweeps a highlight across a placeholder using transform only, instead of pulsing.

CountUp

Preview theme
Preview density

Revenue

$12,480

Conversion

37.2%
<div className="flex flex-col items-start gap-4">
  <div className="flex gap-10">
    <div>
      <p className="text-xs text-muted-foreground">Revenue</p>
      <CountUp
        className="text-3xl font-semibold"
        value={revenue}
        locale="en-NZ"
        format={{ style: "currency", currency: "NZD" }}
      />
    </div>
    <div>
      <p className="text-xs text-muted-foreground">Conversion</p>
      <CountUp
        className="text-3xl font-semibold"
        value={rate}
        locale="en-NZ"
        format={{ style: "percent", maximumFractionDigits: 1 }}
      />
    </div>
  </div>
  <Button
    variant="outline"
    size="sm"
    onClick={() => {
      setRevenue(Math.round(5000 + Math.random() * 95000));
      setRate(Math.random() * 0.6);
    }}
  >
    New numbers
  </Button>
</div>
Open as text

CountUp counts to its new value whenever value changes (set animateOnMount to count up on first show). Format with Intl options (format, locale): currency, percent, decimals. Whole numbers count in whole numbers. If the value changes again mid-count, it carries on from where it is.

Screen readers get the final value at once. The counting itself is hidden from them, so nobody hears a stream of numbers. The digits are tabular so the width does not jitter, and under reduced motion it jumps to the value.

Prop

Type

Transition (page and shared-element transitions)

import { Transition } from "@azmr/ui/transition";

<Transition name="hero">…</Transition>

Transition wraps React's <ViewTransition> for route changes, list changes and shared-element morphs. That component is stable from React 19.3; React 19.2 and earlier do not have it. So Transition looks it up when it renders, under both its stable and experimental names, and where it is missing it renders its children and nothing else. It is safe to use on any supported React, and starts working when you upgrade, with no code change. It also renders nothing extra when the person prefers reduced motion. The cross-fade (opacity only) is in styles.css.

In a Next.js App Router app, the React that runs is the one Next bundles, not the react in your package.json. Next 16.4 bundles a React 19.3 build that has ViewTransition, so there it depends on your Next version.

Prop

Type

useReducedMotion

Preview theme
Preview density

Your system is set to allow motion. Change it in your operating system's accessibility settings and this updates without a reload.

"use client";

import { useReducedMotion } from "@azmr/ui/hooks";

export default function ReducedMotionDemo() {
  const reduced = useReducedMotion();
  return (
    <p className="text-sm" aria-live="polite">
      Your system is set to <strong>{reduced ? "reduce motion" : "allow motion"}</strong>. Change it
      in your operating system's accessibility settings and this updates without a reload.
    </p>
  );
}
import { useReducedMotion } from "@azmr/ui/hooks";

const reduced = useReducedMotion();

Whether the person has asked their system for reduced motion, kept up to date while the app is open. On the server and during hydration it is false, so there is no mismatch. The global CSS already handles CSS animations; use the hook for motion that JavaScript drives (a count-up, a canvas, a video) that CSS cannot reach.

Tokens

TokenValueUse
--motion-duration-instant / fast / base / slow75 / 150 / 200 / 300 msPress feedback, exits, enters, larger movements
--motion-ease-standard / emphasized / decelerate / accelerateMoves, expressive moves, entering, leaving
--motion-stagger-step40 msDelay between staggered items
--motion-duration-ambient1500 msLoop length of ambient effects (shimmer)

Under reduced motion the durations become 0.01 ms (the stagger step, 0).

The motion library (optional)

CSS covers enters, exits and state changes. For movement that follows a gesture (dragging a row, a dropped item settling, layout changes) use the motion library through @azmr/ui/motion. It is optional: nothing else in @azmr/ui imports it, and installing @azmr/ui does not install it.

pnpm add motion

motion is an optional peer dependency (^14.0.0, tested against 14.2.0). If you import @azmr/ui/motion without it installed, the build fails with the usual "cannot find module" error naming motion.

Preview theme
Preview density
  • Write the brief
  • Design the tokens
  • Build the components
  • Ship the docs
<MotionProvider features="max">
  <Reorder.Group
    axis="y"
    values={items}
    onReorder={setItems}
    className="flex max-w-sm flex-col gap-2"
    aria-label="Tasks, in order"
  >
    {items.map((item, index) => (
      <Reorder.Item
        key={item}
        value={item}
        transition={springs.snappy}
        className="flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm"
      >
        <Icon icon={GripVertical} className="text-muted-foreground" />
        <span className="flex-1">{item}</span>
        <IconButton
          icon={<Icon icon={ArrowUp} />}
          label={`Move "${item}" up`}
          size="sm"
          disabled={index === 0}
          onClick={() => move(index, -1)}
        />
        <IconButton
          icon={<Icon icon={ArrowDown} />}
          label={`Move "${item}" down`}
          size="sm"
          disabled={index === items.length - 1}
          onClick={() => move(index, 1)}
        />
      </Reorder.Item>
    ))}
  </Reorder.Group>
</MotionProvider>
Open as text

Wrap the part of the app that animates in MotionProvider, then use m elements (or Reorder, AnimatePresence, useAnimate and the other re-exports):

import { MotionProvider, m, springs } from "@azmr/ui/motion";

<MotionProvider>
  <m.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={springs.gentle} />
</MotionProvider>

MotionProvider sets up two things so you do not have to:

  • Lazy features. It uses LazyMotion, so the animation code loads after first render instead of sitting in your main bundle. features="dom" (the default) covers animation, variants, hover, tap and exit. features="max" adds layout animation, drag and reorder, and downloads more. Either loads on demand; the wrapper itself is about 0.5 kB.
  • Reduced motion. It sets reducedMotion="user", so every animation inside follows the system setting: movement is skipped and fades still play.

By default it is strict: using the full motion.div inside it throws, so the whole library is never pulled into your bundle by accident. Use m.div. Pass strict={false} only if you accept that.

springs has three presets for the transition prop: gentle (a panel gliding into place), snappy (a control responding) and bouncy (a visible overshoot, used sparingly). For plain enters and exits prefer the CSS presets above, which are cheaper and follow the motion tokens.

Prop

Type

Drag needs a keyboard alternative

Dragging is not available to everyone. The demo gives every row up and down buttons with names such as "Move Design the tokens up", and disables them at the ends of the list. Do the same for anything you make draggable.

Writing your own animation

Use a token for every time and easing, animate only transform, opacity, clip-path (or grid-template-rows), and the global reduced-motion rule handles the rest. If something genuinely must keep moving under reduced motion (a progress spinner, which says "still working"), mark it data-motion="essential".

Accessibility

  • Nothing is announced because it animates; motion never carries meaning on its own.
  • Collapse content is inert while closed.
  • CountUp exposes the final value to assistive technology immediately.
  • Every preset has a reduced-motion rule, checked by tests.