Module 01 / CSS MOTION
Motion without JavaScript
Calm motion, declared in CSS
Loading stage…
Module notes
The FVZL site moves the way its products do: calmly. Turn a camera, count a watch's parts, take the headphones apart and page through the line-up, all with motion declared in CSS and enhanced only where the browser supports it.
- This browser
- checking…
- Native path
- Native CSS — no animation library needs Scroll-driven animation + Anchor positioning + View Transitions + Cross-page transitions
- Fallback
- Static plates, same content needs nothing extra
- Status
- Live since 2026-10-08
- Browser support
-
First version with each feature · caniuse, 2026-10-08Feature Chrome Safari Firefox Scroll-driven animation 115 26 Not yet Anchor positioning 125 26 147, partial View transitions 111 18 144 Cross-page transitions 126 18.2 144, partial Popover + @starting-style 117 17.5 129 @property 85 16.4 128 - Tags
How this module works
The stage is plain HTML and one stylesheet. Every movement on it is declared in CSS and run by the browser itself, tied to scroll, to an anchor or to a change of state. Turn JavaScript off and all four plates still work; the slides just change without the move.
With Reduce Motion on, or in a browser without scroll-driven animation (Firefox 157), the same plates hold still: a contact sheet of all twelve angles, the final figures, slides that swap.
-
Fig. 01.1 / Scroll timeline
The turn
The FK01’s plate is three stage-widths wide and starts in its middle, and its picture is sticky, so scrolling either way turns the camera instead of moving it: a full turn each side. A registered integer counts 0 → 24 along the plate’s view timeline, and each of the 12 renders shows when the count, modulo 12, lands on its number. Stepping the animation keeps every engine on whole frames (Safari would otherwise blend two).
@property --frame { syntax: "<integer>"; inherits: true; initial-value: 0; } .plate-turn { view-timeline: --turn inline; } .turn-frames { animation: turn steps(25, jump-none) both; animation-timeline: --turn; animation-range: contain 0% contain 100%; } .turn-frames img { /* 1 on its own angle, 0 on every other */ --d: calc(mod(var(--frame), 12) - var(--i)); opacity: max(0, 1 - var(--d) * var(--d)); } @keyframes turn { to { --frame: 24; } } -
Fig. 01.2 / View timeline
The count
animation-range: entryties an animation to the plate’s arrival rather than to the clock. A counter prints a registered integer, so the FW01’s figures count up with no script, and each module row starts a little later than the one before..count { --n: var(--to); /* the real figure, e.g. 550 */ counter-reset: n var(--n); animation: count linear both; animation-timeline: --sheet; animation-range: entry 15% entry 95%; } .count::before { content: counter(n); } @keyframes count { from { --n: 0; } } -
Fig. 01.3 / Anchor positioning
Take it apart
Scrolling plays the FH01’s explode: 20 frames rendered from its explode animation, picked by the same kind of view timeline as the turn. Once it’s apart, the pins arrive. Each is a button with
popovertarget, so it opens its note without script; the note sits in the top layer, tethered to its pin, and the browser tries the fallbacks when there’s no room above.@starting-stylegives it a state to fade in from. The pins are placed from the parts manifest, projected through the render camera at build time.<button popovertarget="note-power" style="anchor-name: --pin-power"> <div popover id="note-power" style="position-anchor: --pin-power"> .note { position-area: top; position-try-fallbacks: flip-block, --right, --left; transition: opacity .2s, translate .3s, display .3s allow-discrete, overlay .3s allow-discrete; } @starting-style { .note:popover-open { opacity: 0; translate: 0 6px; } } -
Fig. 01.4 / View transition
The line-up
All seven FVZL products on a carousel projector. The one movement that needs script:
startViewTransition()takes a snapshot of the old slide, lets the page change, then animates between the two. Everything about the move is CSS: its direction, and its tempo, which follows Energy.document.startViewTransition(() => show(next)); .proj-frame { view-transition-name: slide; } ::view-transition-old(slide) { animation: var(--vt-dur) var(--ease-calm) both slide-out; } ::view-transition-new(slide) { animation: var(--vt-dur) var(--ease-calm) both slide-in; } -
Fig. 01.5 / Scene state
The control panel, in CSS
The Control panel writes three custom properties. CSS reads them directly: Signal tints the pins, needle and code, Light warms the studio, and Energy sets the tempo. The readout in the stage’s corner formats the values with
round(),mod()and counters./* 0 at noon → 1 at midnight */ --warmth: calc(max(var(--scene-time) - .5, .5 - var(--scene-time)) * 2); filter: sepia(calc(var(--warmth) * .45)) brightness(calc(1 - var(--warmth) * .3)); /* registered <integer>s, printed by counters */ --m: calc(mod(round(var(--scene-time) * 1440), 1440)); --hh: calc(round(down, var(--m) / 60, 1)); --mm: calc(mod(var(--m), 60)); counter-reset: hh var(--hh) mm var(--mm); -
Fig. 01.6 / This page
The site itself
The same features run the site around the stage: the card you opened grew into this page through a cross-page view transition, the hairline along the top of the window is a scroll timeline, and the Status and Control panels are anchored popovers.
@view-transition { navigation: auto; } .scroll-progress { animation: grow linear both; animation-timeline: scroll(root block); }