Component
parallax
Demo
tokens.css,
base.css and parallax.css. No scripts.
Source
Copy both files, or run npx nojsui add parallax.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| overflow: clip | widely | 90 | 90 | 81 | 16 | 90 | 81 | 16 |
| color-mix() | widely | 111 | 111 | 113 | 16.2 | 111 | 113 | 16.2 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Scroll-driven animations | limited | 115 | 115 | — | 26 | 115 | — | 26 |
Usage
A hero band whose backdrop drifts slower than the page as the band passes through the viewport.
<header class="sk-parallax">
<div class="sk-parallax__backdrop"></div>
<div class="sk-parallax__content">
<h1>Depth, with no script</h1>
</div>
</header>
The backdrop is an empty, decorative element with a background. The demo paints a CSS gradient because nothing in the kit ships a binary asset; to use a photo, set one property on that one element:
.sk-parallax__backdrop {
background-image: url(hero.jpg);
}
Content goes in __content, which is a sibling of the backdrop rather than a
child, so nothing you write depends on the moving layer.
How it works
animation-timeline: view() ties the drift to where the band sits in the
scrollport, so the backdrop’s offset is a function of scroll position — it
tracks in both directions rather than replaying a one-shot animation.
Two things in the CSS are not obvious, and both fail silently.
overflow: clip, never overflow: hidden
overflow: hidden makes an element a scroll container — it is
programmatically scrollable even when nothing overflows. view() binds to the
nearest ancestor scroll container, so a band using hidden would capture its
own backdrop’s timeline and the backdrop would never move. overflow: clip
clips without becoming a scroll container.
reveal.css documents the mirror image of the same trap: a wrapper that is a
scroll container but happens not to overflow leaves the timeline inactive, so
nothing animates. Between them, these are the two ways a view() timeline
quietly does nothing.
The slack is derived from the drift
A layer that travels N pixels needs N pixels spare on each side, or it pulls off an edge and shows a gap — intermittently, at some viewport heights only. Rather than pair a distance with a separately-authored overhang, the backdrop is inset by exactly the distance it travels:
.sk-parallax__backdrop {
inset-block: calc(-1 * var(--_distance));
inset-inline: 0;
}
Its size is implied by the insets, so there is no second number that can
disagree with the first. breadcrumb computes its bar from data-step for the
same reason.
The keyframes move translate on the physical block axis. That is deliberate
rather than an oversight of the logical-properties rule: the drift has to follow
the scroll direction, and view() tracks the scrollport’s block axis. The
insets stay logical, so the slack follows the writing mode even where the motion
does not.
Keyboard contract
None. The band contains no focusable elements of its own; anything you place in
__content keeps its own behaviour and tab order, unaffected by the backdrop.
| Key | Behaviour |
|---|---|
Tab | Passes through to whatever you put in __content |
| Any other key | No effect on the band |
Verified manually in VoiceOver and NVDA: yes — the backdrop is an empty
div and is not announced; the content is read normally.
Accessibility notes
The backdrop is empty and decorative, so it contributes nothing to the
accessibility tree and needs no aria-hidden — there is no text to hide. The
heading and copy live in __content, a sibling, so the reading order is
unaffected by the moving layer.
Contrast is provable rather than eyeballed, and it has to be. axe reports
contrast over a gradient as incomplete, not as a violation, so the test suite
will not catch an unreadable pairing here. The default backdrop’s second stop is
color-mix(in oklab, var(--sk-color-accent), var(--sk-color-text) 25%), which
moves away from --sk-color-on-accent in both schemes: --sk-color-text is
near-black on light and near-white on dark, and --sk-color-on-accent is the
reverse. Since tools/tokens/check-contrast.mjs already holds on-accent against
accent at 4.5:1, every point along the ramp is at least that far apart.
Sampled at eleven points per scheme, the worst is 5.56:1 (light, at the
accent stop) and 7.55:1 (dark).
If you replace --sk-parallax-backdrop with a photo or your own gradient, that
guarantee goes with it — check the foreground against the darkest and lightest
parts of your own image.
Reduced motion
The drift is off under prefers-reduced-motion: reduce. The animation lives
inside @media (prefers-reduced-motion: no-preference), so there is no
animation to clamp and the backdrop sits centred.
This is the opposite of what progress does, and the difference is the point.
progress opts out of the reduced-motion clamp with
data-sk-motion="essential" because its motion is its content — frozen, it
reports a value that is wrong. Parallax is decorative depth and a known
vestibular trigger, with nothing lost by holding it still. When in doubt, a
component belongs on this side of the line; progress and toast are the only
two that do not.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
overflow: clip | widely | The backdrop’s slack spills past the band over the surrounding page. This is the floor — the component needs it to be laid out correctly at all. |
color-mix() | widely | Only the default backdrop’s second stop; a custom --sk-parallax-backdrop does not use it. Without it the gradient declaration is invalid and the backdrop falls back to no background. |
animation-timeline: view() | limited — Chrome/Edge 115+, Safari 26+, no Firefox | The backdrop is static. A hero that does not drift is just a hero, so nothing is hidden and nothing looks broken. |
The last row is why the paint sits outside @supports and only the
animation goes inside it — the reverse of progress, which hides itself
entirely because a progress track that can never fill is a lie rather than a
still image.
Theming
| Property | Default | Controls |
|---|---|---|
--sk-parallax-distance | --sk-space-2xl | How far the backdrop drifts — and, by construction, the slack it gets |
--sk-parallax-backdrop | gradient from --sk-color-accent | The backdrop layer’s background; set url(...) for a photo |
--sk-parallax-min-block-size | 20rem | Height of the band |
--sk-parallax-radius | --sk-radius-lg | Corner radius of the band |
--sk-parallax-content-fg | --sk-color-on-accent | Foreground colour over the backdrop |
--sk-parallax-range-start | cover 0% | Where the drift begins, as an animation-range |
--sk-parallax-range-end | cover 100% | Where it ends |
Distance is a theme knob because it is taste. The insets are not exposed separately: they are computed from distance so the two cannot disagree, which is correctness rather than appearance (CLAUDE.md rule 6).