Animation rules
Who is this for
Section titled “Who is this for”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(ortranslate,scale,rotate) andopacity. 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: reduceeverything 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 auditenforces most of this. A rule marked “convention” below has no script behind it.
Where the vocabulary lives
Section titled “Where the vocabulary lives”| 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.
Tokens
Section titled “Tokens”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 |
The single spring
Section titled “The single spring”--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 asvar(--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.
Durations outside the tokens
Section titled “Durations outside the tokens”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.
What may be animated
Section titled “What may be animated”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.
The listed exceptions
Section titled “The listed exceptions”| 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 andopacity, with--dur-fastor--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-rowwithhov-icon, andpeel(a showcase sticker lifting a corner, only withhover: hoverandpointer: fine). Reuse one before writing another.
Pointer-following effects
Section titled “Pointer-following effects”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.
Scroll reveals
Section titled “Scroll reveals”Content below the hero appears as it is scrolled to, once.
- An inline script in
<head>(REVEAL_GATE) runs before the first paint. It putsdata-reveal="armed"on<html>and starts oneIntersectionObserver. - An element opts in with
data-rv="head","text","item","card","line"or"lines".--rv-ion the element is its place in a series. - When the element crosses the trigger line, the script sets
data-shownand stops observing it. CSS plays the arrival. - A client component that adds a
data-rvelement after load callsarmReveals().
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.
What may loop forever
Section titled “What may loop forever”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 | — |
The gate: useMotionGate
Section titled “The gate: useMotionGate”const { ref, active, reduced } = useMotionGate<HTMLDivElement>({ threshold: 0.2 });active is true only while all three hold:
- the element is on screen (
IntersectionObserver, default threshold 0.2); - the tab is visible (
document.hiddenis false); - 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.
How the audit enforces it
Section titled “How the audit enforces it”| 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.
Reduced motion
Section titled “Reduced motion”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.
Scroll-driven animations
Section titled “Scroll-driven animations”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 |
Fallbacks
Section titled “Fallbacks”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.
Two rules that are easy to break
Section titled “Two rules that are easy to break”- 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-driftelement below the fold with opacity between 0.01 and 0.99. - In place at mid-screen. Scrolled to the centre of the viewport, a
.sd-driftelement 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.
The hero scene
Section titled “The hero scene”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.
Sequence and durations
Section titled “Sequence and durations”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.
Life cycle
Section titled “Life cycle”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 |
Composition rules
Section titled “Composition rules”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-slowwith--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.
View Transitions
Section titled “View Transitions”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
startViewTransitiondoes 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.
Forbidden patterns
Section titled “Forbidden patterns”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 |
Checklist: before adding an animation
Section titled “Checklist: before adding an animation”- Does it need to exist? If it explains nothing and confirms nothing, leave it out.
- Can an existing class do it? Check
data-rv, thehov-*classes,.sd-drift,.sheen,flip-inandflip-out. - Transform and opacity only? If not, it needs a comment and an entry in the repository’s
CLAUDE.md. - Token durations and one of the three curves? A literal duration needs a comment.
- Does it loop? Then it must be on the allow-list, pass through
useMotionGate, and be added to the list inCLAUDE.mdand on this page. - 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 underno-preference. - No JavaScript? The resting state must be the readable one.
- Does a hover move a neighbour or change layout? It must not.
- 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. - Does it shift layout? Reserve the space first. The audit fails on a cumulative layout shift above 0.001.
- Run it.
npm run shots,npm run audit, andnpm run filmto actually watch it. See Landing quality tooling.
Where to go next
Section titled “Where to go next”- Landing quality tooling — the audit checks referenced on this page, in full.
- Landing component — where the scene and sections sit in the page.
- i18n and copy — text length limits that interact with the hero layout.
- Playbook: a new product in the line — reusing this vocabulary on another landing page.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.