Motion
Animation in duck/ui is either idle or reactive. Idle motion tells you a surface is alive. Reactive motion answers something the user did. Nothing moves for decoration.
Two kinds
Idle animation runs while nothing is happening: a button breathing, a sheen crossing a pill, a sticker floating. It has one job, to signal that a control is live, so the budget is one idle element per viewport. Two competing loops read as a rendering bug.
Reactive animation answers an action: a press squash, a ripple from the exact pointer coordinate, a check mark popping in when a request resolves. There is no budget for these because each one is caused by the user.
Durations sit between 150ms and 400ms. Entrances use --ease-duck, presses use --ease-squash, which overshoots slightly and settles.
Vocabulary
Keyframes installed by the theme. Reference them from any component.
duck-idleBreathes once every five seconds. Resting controls.duck-sheenLight passes over a filled surface on a slow loop.duck-floatBobs like something on water.holo-shiftDrifts the iridescent gradient.duck-squashPress feedback with overshoot.duck-popAn element arriving, with a little overshoot.duck-rippleExpanding ring from a press point.duck-paddleThe duck glyph paddling, used while loading.duck-caretTerminal cursor blink.duck-riseEntrance from below. Toasts, tab panels.duck-marqueeContinuous horizontal travel.duck-shimmerSkeleton placeholder sweep.Composed transforms
The trick that lets several effects share one property without fighting.
/* Hover lift, pointer magnetism and press squash all want the
transform property. Composing them from variables means they
add up instead of overwriting each other. */
.quack {
transform:
translate(var(--mx, 0px), calc(var(--my, 0px) + var(--lift, 0px)))
scale(var(--press, 1));
transition: transform 300ms var(--ease-duck);
}
.quack:hover { --lift: -2px; }
.quack:active { --press: 0.96; transition-duration: 75ms; }Pointer tracking
Foil, tilt and magnetism all read the same four variables, written by one hook.
"use client"
import { useHoloPointer } from "@/hooks/use-holo-pointer"
export function FoilCard() {
// Writes --fx, --fy, --rx, --ry straight onto the node inside a rAF.
// React never re-renders while the pointer moves.
const ref = useHoloPointer<HTMLDivElement>({ tilt: 10, magnet: 6 })
return <div ref={ref} className="foil tilt size-48 rounded-2xl" />
}The hook measures the element once on pointer enter, then writes values inside a single animation frame. Nothing is stored in React state, so a page can carry many foil surfaces without a render storm.
Reduced motion
The theme collapses every animation and transition when the system asks for reduced motion, and disables tilt outright. Components that would otherwise animate content into place check the media query themselves: the terminal, for example, prints its whole transcript at once instead of typing it.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.tilt { transform: none !important; }
}