Skip to content

Animation rules

A developer who is about to add or change any movement on the landing page: a hover, a reveal, a step of the hero scene, a looping decoration.

  • Five duration tokens, two easing curves and exactly one spring. Use the tokens; do not invent a new curve.
  • Animate transform (or translate, scale, rotate) and opacity. Three documented exceptions exist; hover may additionally change colour and shadow but never a layout property.
  • Nothing loops forever unless it is on the short allow-list and stops when it is off screen, in a hidden tab, or under reduced motion. The shared gate is useMotionGate.
  • Under prefers-reduced-motion: reduce everything is visible at once, the hero scene stands on its final frame, and nothing moves.
  • No animation library. All motion is CSS, the Web Animations API (two places) and View Transitions (theme and language switch).
  • npm run audit enforces most of this. A rule marked “convention” below has no script behind it.
What File
CSS tokens, keyframes, reveal and hover classes, reduced-motion overrides src/app/globals.css
useMotionGate, useReducedMotion, the same numbers as JS constants src/lib/motion.ts
The scroll-reveal observer (inline script) src/lib/reveal.ts
Hero scene sequence and timings src/components/hero/scene/steps.ts
Hero scene driver src/components/hero/scene/useSceneMachine.ts
Device tilt src/components/hero/scene/useDeviceTilt.ts
Telegram mock-up motion src/styles/telegram.css

The vocabulary was taken from the first landing page in the product line and kept structurally the same. See the playbook for what is shared.

Defined on :root in globals.css.

Token Value Use it for
--dur-fast 180 ms hover and focus
--dur-base 400 ms something arriving or leaving
--dur-slow 650 ms one scene giving way to another; devices swapping roles; view transitions
--dur-scene 900 ms the big ones: a kiosk screen giving way to the next
--dur-flip 260 ms a status or number turning over in place
--dur-pack 900 ms a sticker flying into the pack
--ease-out cubic-bezier(0.22, 1, 0.36, 1) everything that arrives, settles or leaves
--ease-in-out cubic-bezier(0.65, 0, 0.35, 1) everything that turns over in place: a flip, a swap
--ease-spring a linear() curve landing and sticking, see below

Other constants:

Constant Value Source
Rise distance of an arriving element 20 px (RISE) motion.ts
Stagger between pack stickers 40 ms (STAGGER_PACK, calc(var(--i) * 40ms)) motion.ts, globals.css
Reveal trigger line 15 % up from the bottom of the viewport reveal.ts

--ease-spring is a damped spring (damping ratio about 0.72) sampled into a CSS linear() function. It overshoots its target by under 4 % and settles. There is no spring library.

  • Browsers without linear() get --ease-out: the token is declared as var(--ease-out) and overridden inside @supports (transition-timing-function: linear(0, 1)).
  • Use it only where something lands and sticks: a sticker seating into the pack, stickers slapped on at load, timeline dots and icons popping, a showcase sticker flipping in, an icon lifting on row hover.
  • Do not add a second spring with different parameters.

The repository rule says durations come from --dur-* only, except the beats of the kiosk scenario that are tied to step lengths. In the code as it stands, a number of one-off literals exist as well, for example 500 ms (rv-pop), 520 ms (flip-in, k-leave), 640 ms (stk-slap) and 1200 ms (the burst). Treat the rule as a convention for new work: reach for a token first, and if a literal is needed, comment why.

Rule: transform and opacity only. The individual transform properties translate, scale and rotate count as transform. Hover classes use those rather than transform, so a hover never fights a transform set elsewhere.

Exception Property Where Why it is allowed
FAQ answer opening grid-template-rows on details::details-content globals.css The only way to animate to auto height. Without ::details-content support the answer simply appears
Form section swapping to the “sent” screen height, set inline RequestForm.tsx The section settles to its new height instead of yanking the page under the reader
The success tick being drawn stroke-dashoffset .drawn-check-ring, .drawn-check-tick Drawing a stroke cannot be done with transform

Each is marked with a comment in the code. A fourth exception needs the same: a comment, and an entry in the repository’s CLAUDE.md.

  • Hover, active and focus-within rules must not set any layout property. Enforced: the audit walks every stylesheet rule and fails on width, height, padding, margin, border widths, offsets, font-size, gap, flex and grid sizing and similar.
  • Hover may change translate, scale, rotate, filter, box-shadow, colours and opacity, with --dur-fast or --dur-flip.
  • A hover never moves a neighbour.
  • Existing hover classes: hov-btn, hov-magnet, hov-link, hov-surface, hov-sticker, hov-field, hov-logo, hov-row with hov-icon, and peel (a showcase sticker lifting a corner, only with hover: hover and pointer: fine). Reuse one before writing another.

Only two things follow the pointer, both written straight to an element’s style inside a pointer handler, with no React state:

Effect Limits File
The hero CTA leans towards a nearby pointer within 120 px, at most 8 px of travel MagneticCta.tsx
The hero tablet and phone tilt towards the mouse at most 5° turn and 3.5° nod; mouse events only; never under reduced motion useDeviceTilt.ts

Both run a requestAnimationFrame loop only while catching up, and stop when they arrive. The audit accepts a combined tilt of up to 6°, requires it to return to zero when the mouse leaves, and fails if a touch makes the devices lean.

Content below the hero appears as it is scrolled to, once.

  1. An inline script in <head> (REVEAL_GATE) runs before the first paint. It puts data-reveal="armed" on <html> and starts one IntersectionObserver.
  2. An element opts in with data-rv="head", "text", "item", "card", "line" or "lines". --rv-i on the element is its place in a series.
  3. When the element crosses the trigger line, the script sets data-shown and stops observing it. CSS plays the arrival.
  4. A client component that adds a data-rv element after load calls armReveals().

The gate is not armed when IntersectionObserver is missing or under reduced motion. The hidden state then never applies and the page is simply there. This is why the script is inline and not a React effect: armed from an effect, a fast phone paints the section, hides it, and brings it back.

The H1 does not use this. It rises with a plain CSS animation at first paint so the first screen never waits for JavaScript. See Landing quality tooling.

Infinite animations are an allow-list. Everything on it is gated.

Loop Keyframes Period Gate Extra pause
Warm light spots behind the hero spot-drift-a, spot-drift-b 26 s and 34 s, alternating useMotionGate, threshold 0.05 —
The hero scene timer-driven steps one cycle is about 26 s its own machine, see below mouse on the scene, keyboard focus on the stepper; sleeps after three cycles
The hero CTA’s breathing ring cta-breathe 3 s useMotionGate, threshold 0.4, as data-breathe="on" paused while hovered
“Typing” dots in the Telegram header of the “Why” chat typing-dot 1.2 s the chat’s own observer, as data-running stops when the chat has finished
The sticker ribbon ribbon-left, ribbon-right 64 s and 76 s useMotionGate, threshold 0, as data-running paused while hovered
The highlight on the form’s submit button button-shimmer 1.2 s exists only while a request is being sent —
const { ref, active, reduced } = useMotionGate<HTMLDivElement>({ threshold: 0.2 });

active is true only while all three hold:

  1. the element is on screen (IntersectionObserver, default threshold 0.2);
  2. the tab is visible (document.hidden is false);
  3. the visitor has not asked for reduced motion.

Attach ref to the animated element and drive the animation from active — typically a data attribute that switches animation-play-state or adds the animation. There is one implementation so that “stop when nobody is looking” cannot be remembered in one component and forgotten in another.

useReducedMotion() starts as true and flips after mount. The server has no opinion, and starting from “no motion” keeps the first client render identical to the server markup.

Situation What may still run
Tab hidden no infinite animation at all
Scrolled to the bottom of the page no infinite animation at all; the scene step must not change
First screen after the scene has gone to sleep only animations on elements whose class contains spot or cta-breathe
Reduced motion no infinite animation at all

A new loop that is not gated fails the first two rows immediately.

With prefers-reduced-motion: reduce:

Area Behaviour
Scroll reveals gate never armed; everything visible from the start
Hero scene stands on pack, the final frame; the pack is simply there, not slapped on. A stepper click shows that step and stays on it
Loops none run
Hover colour only; no travel, no sheen, no peel
Device tilt off
Scroll-driven effects none; the reading line is display: none
Sticker burst after sending display: none
Theme and language switch instant; no view transition is started
“Why” chat all six messages present from the start
Setup timeline line whole, all stages lit
Smooth scrolling scroll-behavior: auto

The audit takes two screenshots of the hero three seconds apart under reduced motion and requires them to be byte-identical.

The same final frame is what a visitor without JavaScript gets, because the server renders pack.

Three effects are driven by the scroll position itself through CSS animation-timeline. There is no script and no observer. They are not loops: when the page stands still, so do they.

Effect Class Timeline What it does
Reading line under the header .read-progress scroll(root block) a 2 px line in accent-ink scales from 0 to 1 across the page
Mock-ups below the hero .sd-drift view(), range cover 0% to cover 100% comes up from 64 px lower and 0.94 scale at opacity 0, is whole by about a fifth of the way, drifts 28 px ahead on the way out
The pinned panel in “How it works” .how-drift named --how view timeline on #how, range contain drifts ±28 px against the steps

All three live inside @media (prefers-reduced-motion: no-preference) and @supports (animation-timeline: …). Where either condition fails, none of the rules apply and every element stands in its normal place. The reading line is display: none by default and only shown inside the @supports block.

  1. Off-screen means opacity 0, not half-transparent. A mock-up at 0.35 opacity below the fold is text with failing contrast to an accessibility checker. The keyframes start at exactly 0. The audit fails on any .sd-drift element below the fold with opacity between 0.01 and 0.99.
  2. In place at mid-screen. Scrolled to the centre of the viewport, a .sd-drift element must be at opacity ≥ 0.99, within 24 px of its resting position and at scale ≥ 0.995.

.sd-drift replaces the element’s one-off reveal; its declarations carry !important so the scroll timeline wins over the reveal’s resting state.

One step — one device in focus — one action. Everything the scene shows is derived from one value, the current step. There are no independent timers inside the scene, so it cannot end up between two states. The small beats inside a step (a finger tap, the 3-2-1, the flash) are CSS animations that start with the step and finish inside it.

From STEP_MS in steps.ts:

Step Duration Device in focus The one action
(first frame) pack 2500 ms hold (INTRO_HOLD_MS) none: the assembled pack —
retract (opening only) 1500 ms phone stickers fly back into the phone
start 3200 ms tablet finger taps Start
qr 3600 ms tablet, phone comes to the QR (scan) a green tick on the QR
consent 3600 ms phone finger taps “I agree”
outfit 3400 ms tablet finger taps an outfit card
photo 4000 ms tablet 3-2-1 and a flash
delivered 3400 ms phone the pack preview arrives in the chat
pack 3400 ms none: the stickers laid out stickers fly out, 40 ms apart, on the spring
idle 1200 ms none a breath before the next guest

One cycle from start to idle sums to 25.8 s. A unit test (steps.test.ts) requires every step except idle to last between 3000 and 4000 ms.

The stepper above the scene has six entries: Start, QR, Consent, Outfit, Photo, Pack. The last one covers delivered, pack and idle.

Verified in useSceneMachine.ts:

stateDiagram-v2
    [*] --> Pack: server render
    Pack --> Retract: on screen for 2.5 s
    Retract --> Cycle: after 1.5 s
    Cycle --> Cycle: next step
    Cycle --> Resting: third cycle ends
    Resting --> Cycle: stepper click or hero re-entered
Behaviour Detail
First frame The server renders pack. While the opening is in progress the tablet and the phone are not drawn at all, so nothing reads through the pack
Starts when at least 20 % of the scene is on screen and the tab is visible
Stops when the scene scrolls out, the tab is hidden, or reduced motion is on
Pause a mouse pointer on the scene, or keyboard focus (:focus-visible) inside the stepper. The remaining time of the current step is kept, so the story resumes exactly where it was
Sleeps after three cycles (CYCLES_BEFORE_REST = 3) it parks on pack and the page goes quiet
Wakes a stepper click jumps to that step and plays one more cycle. Scrolling away and back resets the count and the story starts over
Freeze for tooling ?step=<name> freezes on one step

All enforced by the audit unless noted.

  • The device a step is about is fully opaque; the other is faint (at most 0.45 opacity) or out of the frame on a phone-width screen.
  • Behind the phone, the tablet is a frame around an empty screen: no text to read past the phone.
  • At most one finger per step. Its centre is beside the label’s letters, not on them.
  • Scanning shows a green tick on the QR. No beam, no camera frame.
  • The phone never covers the tablet’s words, the Start button, the QR or the outfit cards.
  • While the pack is on screen, tablet and phone step back to opacity ≤ 0.12 and scale 0.96.
  • Kiosk screens cross-fade: the leaving screen stays the same element and fades (k-leave), the new one rises 120 ms later (k-enter). There is never an empty frame. (Convention.)
  • Tablet and phone swap roles on one curve: --dur-slow with --ease-in-out. (Convention.)
  • The chat column inside the phone shifts smoothly when a message arrives (FlipStack, Web Animations API). Messages have no opacity or mask at rest.

Used in exactly two places, both a cross-fade of the whole root.

Trigger Implementation Detail
Theme toggle document.startViewTransition(swap) in ThemeToggle.tsx the DOM update is synchronous
Language switch document.startViewTransition(…) in LocaleSwitch.tsx the transition waits until the new locale has rendered, with a 1.2 s safety timeout so a slow network cannot freeze the page; router.replace(…, { scroll: false }) keeps the scroll position
  • Duration --dur-slow, easing --ease-out, set on ::view-transition-old(root) and ::view-transition-new(root).
  • Where startViewTransition does not exist, or under reduced motion, the change is instant. The code checks both before starting.
  • The audit requires exactly one transition per language switch, resolved in under 1100 ms.

Do not add named view-transition groups or per-element transitions without discussing it: the cross-fade is deliberately the only one.

From the repository rules. None of these has a dedicated script except where noted.

Forbidden Note
Any animation or UI library: motion, GSAP, three.js, Lottie, Magic UI, React Bits, shadcn The skills folder in the repository is reference reading only
Text animated letter by letter Section headings rise line by line instead (SplitLines)
Confetti One exception: ten demo stickers burst out of the tick after a fresh form submit, 1.2 s, once, never on reload
Blobs, 3D spheres, neon, glassmorphism The hero’s light spots are warm neutral ink at 4–6 %, never amber or coral
Counters and animated numbers Also forbidden as content
A loop that is not on the allow-list Enforced by the audit in a hidden tab and below the fold
A hover that changes layout Enforced by the audit
Animating width, height, top, left, margin Three listed exceptions only
An arrival from off the screen Elements rise 20 px, never fly in
A half-transparent mock-up at rest Enforced for Telegram mock-ups and for .sd-drift
content-visibility: auto on sections Breaks anchor jumps and the scroll-driven setup line
More than one thing moving on the hero CTA The breathing ring pauses while the sheen crosses
  1. Does it need to exist? If it explains nothing and confirms nothing, leave it out.
  2. Can an existing class do it? Check data-rv, the hov-* classes, .sd-drift, .sheen, flip-in and flip-out.
  3. Transform and opacity only? If not, it needs a comment and an entry in the repository’s CLAUDE.md.
  4. Token durations and one of the three curves? A literal duration needs a comment.
  5. Does it loop? Then it must be on the allow-list, pass through useMotionGate, and be added to the list in CLAUDE.md and on this page.
  6. Reduced motion? Add the element to a @media (prefers-reduced-motion: reduce) block, or build it so the final state is the default and motion is added only under no-preference.
  7. No JavaScript? The resting state must be the readable one.
  8. Does a hover move a neighbour or change layout? It must not.
  9. Is it inside the hero scene? It must start with the step, finish inside the step’s duration, and add no timer of its own. If you change a duration in STEP_MS, keep it between 3000 and 4000 ms.
  10. Does it shift layout? Reserve the space first. The audit fails on a cumulative layout shift above 0.001.
  11. Run it. npm run shots, npm run audit, and npm run film to actually watch it. See Landing quality tooling.

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.