foundation

Motion & Focus

Transitions, animations, reduced motion and the focus ring.

Motion in Pindoba is small and shared: one easing curve for state changes, four ready-made animations, one rule for users who ask for less movement, and one focus ring color that flips with the theme mode. There are no duration tokens, so durations are literal values such as 200ms.

Try it

Pick a motion, replay it on a Button, Panel or Badge, and watch its easing curve. Use the focus playground at the bottom to Tab through real controls.

Motion
Shown on
Preferences
animation: spin

1s infinite · Rotates 360deg at a constant speed. Loading indicators.

linear
cubic-bezier(0, 0, 1, 1)
Focus playground

Press Tab to move through the controls. The ring is the focusRing token, near-white on dark and near-black on light. Focused: nothing yet.

pdbO
focusRing token

Transitions: pindobaTransition

The pdbT utility transitions all properties over the duration you pass, with the system easing cubic-bezier(0.770, 0.000, 0.175, 1.000): a slow start, a fast middle and a soft landing. Use it on interactive elements so every state change feels the same.

import { css } from "@pindoba/styled-system/css";

<button
  className={css({
    pdbT: "200ms",
    backgroundColor: "primary.surface.peak",
    _hover: { backgroundColor: "primary.surface.base" },
  })}
>
  Smooth hover
</button>;

Animations

Four animation tokens ship with matching keyframes in the preset. Reference them with the animation property.

tokens · animations
tokenvaluedescription
spinspin 1s linear infiniteRotates 360deg. Loading indicators.
pingping 1s cubic-bezier(0, 0, 0.2, 1) infiniteScales to 2x and fades out. Attention pulses.
pulsepulse 2s cubic-bezier(0.4, 0, 0.6, 1) infiniteDips to the opacity.subtle token. Skeletons.
bouncebounce 1s infiniteMoves up 25% and settles. Scroll hints.
<span className={css({ animation: "spin" })}>...</span>

Keyframes must live in the preset (keyframes.ts). A @keyframes rule written inline inside a recipe is emitted in the wrong place and does not run.

Reduced motion

People who set “reduce motion” in their OS expect no looping or large movement. Panda’s _motionReduce condition targets @media (prefers-reduced-motion: reduce). Switch the animation off there, and shorten transitions to zero:

<span
  className={css({
    animation: "spin",
    _motionReduce: { animation: "none" },
  })}
/>

The explorer’s “simulate reduced motion” toggle applies the same rules through an attribute, because a page cannot change the OS media query. Real users get the media query.

Focus

Focus is drawn by the focusRing semantic color token: near-white (contrast.lightest) in dark mode and near-black (contrast.darkest) in light mode. Recipes and utilities read the token, so none of them carries a per-mode override.

pindobaOutline

pdbO draws the standard ring: a 2px solid outline, 1px offset, position: relative and z-index: 1, colored with focusRing. Apply it under _focusVisible so it shows for keyboard users only.

<div tabIndex={0} className={css({ _focusVisible: { pdbO: true } })}>
  Focusable
</div>

_focusVisibleWithin

The preset adds one custom condition, focusVisibleWithin, which matches an element when any descendant has :focus-visible or carries data-focus-visible. Use it when a wrapper draws the ring for an inner or hidden input, as custom checkboxes and radios do.

import { visuallyHidden } from "@pindoba/styled-system/patterns";

<label
  className={css({
    _focusVisibleWithin: {
      outlineColor: "focusRing",
      outlineStyle: "solid",
      outlineWidth: "2px",
      outlineOffset: "2px",
    },
  })}
>
  <input type="checkbox" className={visuallyHidden()} />
  Custom control
</label>;

Best Practices

  • Use pdbT for every interactive state change so the easing stays consistent
  • Always pair animation with _motionReduce: { animation: "none" }
  • Draw focus with focusRing (or pdbO), never a hard-coded color
  • Use _focusVisible, not _focus, so mouse clicks do not leave a ring