Guidelines
Motion
Curves
:root in packages/design-system/styles/system/easings.css. Use them in CSS as ease-(--ease-default) or transition-timing-function: var(--ease-default).--ease-outcubic-bezier(0.28, 0.44, 0.49, 1)A gentler arrival that starts moving at once. Hovers and small UI.
--bouncecubic-bezier(0.6, 0, 0.1, 1.4)Overshoots and settles. For a playful moment, once per page at most.
--ease-out-softcubic-bezier(0.28, 0, 0.49, 1)Like ease-out with a softer start. Fades and colour.
--ease-in-out-softcubic-bezier(0.72, 0, 0.28, 1)Things that travel from A to B and stop: carousels, sliders.
--ease-in-out-hardcubic-bezier(0.77, 0, 0.175, 1)Big, deliberate moves: page transitions, a full-screen menu.
--ease-defaultcubic-bezier(0.5, 0, 0, 1)The house curve, and --default-ease. A quick start and a long, soft landing: reveals, panels, anything that arrives.
In use
- Branding
- Websites
- Marketing
Durations
| Moment | Duration | Where |
|---|---|---|
| Hover fill on .bw-button | 300ms | styles/globals.css |
| Accordion and collapsible | 200ms | styles/globals.css, @theme inline |
| FadeUp entrance, 20px of travel | 600ms | components/animations/fade-up.tsx |
| Stagger between items in a row | 60 to 120ms | Index cards, prototypes |
| Page transition, a cross-fade | 1300ms | app/(website)/template.tsx |
Interface feedback stays under 300ms. Content that enters runs 500 to 700ms. Only whole-page moves take longer than a second.
Tools
motion/react (Motion, not framer-motion) for component state and entrances. GSAP with ScrollTrigger, set up in apps/web/components/gsap, for scroll-linked timelines; it runs on the Tempus clock and reads Lenis scroll, so both stay in sync."use client";
import { motion, useReducedMotion } from "motion/react";
export function Reveal({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : 20 }}
transition={{ duration: 0.6, ease: [0.5, 0, 0, 1] }} // --ease-default
viewport={{ once: true, amount: 0.3 }}
whileInView={{ opacity: 1, y: 0 }}
>
{children}
</motion.div>
);
}import { easings } from "@repo/design-system/easings";
gsap.to(element, {
yPercent: -20,
ease: "none", // scrubbed timelines stay linear
scrollTrigger: { trigger: element, scrub: true },
});
// For a timed tween, pass a named curve
element.animate(frames, { duration: 600, easing: easings["out-quint"] });easings.ts
packages/design-system/easings.ts, the classic Penner set plus gleasing, for JavaScript animation.in-quadin-cubicin-quartin-quintin-expoin-circout-quadout-cubicout-quartout-quintout-expoout-circin-out-quadin-out-cubicin-out-quartin-out-quintin-out-expoin-out-circgleasingKeyframes in styles/system/animations.css: pulse, fadeIn, accordion-down, accordion-up, collapsible-down, collapsible-up, grow-left-to-right, grow-right-to-left, grow-top-to-bottom, grow-bottom-to-top, slide-up.
Rules
- Animate transform and opacity. Never width, height, top or left on anything that reflows the page.
- Respect reduced motion: with
useReducedMotion, travel and scale go, fades stay. - Import from motion/react. framer-motion is not a dependency.
- Scroll-linked motion is scrubbed and linear (ease: none). Easing belongs to timed tweens.
- Exits are quicker than entrances; hovers return without delay.
- --bounce is the exception, not the default. Nothing loops or moves on its own except a ticker.