Parallax
A page-scroll-driven band whose layers drift as it passes through the viewport, in CSS alone. Motion stops entirely for visitors who ask for less, and browsers without scroll-driven animation show the same still composition.
Import
import { Parallax, ParallaxLayer } from "@hyzer-labs/ui"Demo
A band with no in-flow content has no height. Give it one with style, a class, or real content, the way every demo below does with min-height. Most of the demos below sit in a bounded, scrollable box so they fit on
this page. Scroll inside that box to see the layers move.
That box is part of these docs, not part of how the component works. On an ordinary page, the
page's own scroll drives the same thing, with no box around it. The band below shows it: it sits
in normal page flow with nothing wrapping it, so keep scrolling this page and its layer drifts
right along with it. It has no Example frame either, because that bordered box would
become a scroll container of its own. That is the mistake the console warning on this page describes,
a wrapper that quietly becomes the scroll container.
<!-- no ScrollStage, no wrapper — this band sits in normal page flow -->
<Parallax as="section" style="min-height: 16rem">
<ParallaxLayer y="6rem">…</ParallaxLayer>
</Parallax>This band lives in the page's own scroll, with no bounded box and no wrapper around it.
A slow background layer behind foreground copy. The layer is a plain ParallaxLayer holding an Image. The copy is an ordinary Container child, so it sets the band's height and sits above the layer by
default (layers default to z-index: -1). Make the art itself taller than
the band, the way a real hero background is. The layer's own bleed already covers its
travel, so a generously tall source image keeps every edge out of sight while it drifts.
Copy sits above the drifting background
The background drifts as you scroll; the copy stays put.
<Parallax as="section" style="min-height: 60vh">
<ParallaxLayer y="8rem">
<Image src="/photos/ridge.jpg" alt="" fit="cover" style="width: 100%; height: 100%" />
</ParallaxLayer>
<Container>
<h2>Copy sits above the drifting background</h2>
</Container>
</Parallax>Two layers with opposite x travel drift sideways as the page scrolls
vertically. This is the effect people usually mean by "horizontal parallax". Travel is a
distance, not a speed: it sets how far a layer moves over the band's whole pass through
the viewport. So this is drift on a vertical scroll, unlike the next
tab, where the band's own scroller runs sideways.
<Parallax style="min-height: 60vh">
<ParallaxLayer x="-6rem">…</ParallaxLayer>
<ParallaxLayer x="6rem">…</ParallaxLayer>
</Parallax>axis picks which axis of the nearest scroller drives the drift: axis="x" tracks the band's own horizontal crossing instead of the page's
vertical scroll. A band used as a panel inside a HorizontalScroll needs axis="x", or its layers sit still, because the shell never scrolls
vertically. Scroll the box below sideways with the scrollbar, a trackpad, touch, or the
keyboard to see two effects. In panel one the circles start far apart, near the top and
bottom, and opposing y (cross-axis) travel brings them together as you
scroll. In panel two the shapes start staggered, and giving each layer a different x travel drifts them at different speeds, the classic depth look.
Panel one
Panel two
<HorizontalScroll style="--hz-horizontal-scroll-height: 20rem">
<!-- panel one: two circles start far apart, near the top and bottom,
and opposing y (cross-axis) travel brings them together as you
scroll right -->
<Parallax axis="x" style="min-height: 100%">
<ParallaxLayer y="16rem">…top, drifts down…</ParallaxLayer>
<ParallaxLayer y="-16rem">…bottom, drifts up…</ParallaxLayer>
</Parallax>
<!-- panel two: classic speed-difference drift — staggered layers,
same-axis x travel at different magnitudes -->
<Parallax axis="x" style="min-height: 100%">
<ParallaxLayer x="4rem">…</ParallaxLayer>
<ParallaxLayer x="9rem">…</ParallaxLayer>
<ParallaxLayer x="16rem">…</ParallaxLayer>
<ParallaxLayer x="22rem">…</ParallaxLayer>
</Parallax>
</HorizontalScroll>Three layers with increasing travel read as depth: the layer that feels farthest away
moves least. A fourth layer sets z to 1 so it drifts in front of
the copy instead of behind it, which shows how the stacking order works.
Copy in the middle of the stack
<Parallax style="min-height: 60vh">
<ParallaxLayer y="2rem">…back</ParallaxLayer>
<ParallaxLayer y="5rem">…mid</ParallaxLayer>
<ParallaxLayer y="10rem">…front</ParallaxLayer>
<Container><h2>Copy</h2></Container>
<ParallaxLayer y="3rem" z={1}>…drifts in front of the copy</ParallaxLayer>
</Parallax>Stacked full-screen sections come from composing, with no extra prop. Put your own position: sticky wrapper outside each Parallax band. The band clips, so a sticky element placed inside it would only
stick within its own bounds. The box below is already its own scroll container, which also
shows that the drift tracks whichever scroller is nearest, not only the page.
Section one
Section two
Section three
<!-- the sticky wrapper is your own CSS, OUTSIDE Parallax — the band
clips, so a sticky element placed INSIDE it would only stick
within its own bounds, not the page's -->
<div class="sticky-section">
<Parallax as="section" style="min-height: 100vh">
<ParallaxLayer y="6rem">…</ParallaxLayer>
<Container><h2>Section one</h2></Container>
</Parallax>
</div>
<div class="sticky-section">…section two…</div>
.sticky-section {
position: sticky;
top: 0;
}--hz-parallax-range narrows which part of the band's pass through the viewport
the drift is spread over. Pick a range, then scroll the box again to see the difference.
<ParallaxLayer style="--hz-parallax-range: cover">…</ParallaxLayer>Travel can be tuned as well, set per breakpoint instead of hardcoded. Set --hz-parallax-x/--hz-parallax-y in your own class and leave
the x/y props unset. The props write an inline style, which always
wins over a stylesheet rule, so use one form or the other. On a short viewport a large
travel reads as jitter and costs battery, so a smaller travel (or none) below your md breakpoint is worth doing.
/* your own CSS — omit the x/y props so this stylesheet wins */
.hero-art { --hz-parallax-y: 4rem; }
@media (min-width: 968px) {
.hero-art { --hz-parallax-y: 12rem; }
}
<ParallaxLayer class="hero-art">…</ParallaxLayer>Props
| Name | Type | Default | Note |
|---|---|---|---|
as | string | 'div' | Rendered via <svelte:element>. 'section' is the common choice for a decorative band. |
axis | 'y' | 'x' | 'y' | Which scroller axis drives the drift. 'y' (default) is the page's vertical scroll. 'x' tracks the band's horizontal crossing. Set it when the band is a panel inside a HorizontalScroll, or its layers will sit still. |
children | Snippet | — | Layers and foreground content, in normal flow. |
class | string | — | Merged after the hz-parallax class. |
Anything not listed above is forwarded as an attribute to the root element (or the native control in form components). So id, data-*, aria-*, and event handlers just work.
ParallaxLayer
| Name | Type | Default | Note |
|---|---|---|---|
x | string | number | 0 | Total horizontal travel across the band's pass through the viewport. A number is px; a string is a CSS length used verbatim. Negative drifts the other way. |
y | string | number | 0 | Total vertical travel. Same rules as x. |
z | number | -1 | z-index inside the band's own stacking context. The default sits the layer behind foreground content; 1 puts it in front. |
children | Snippet | — | The layer's art. Always non-interactive; see Accessibility. |
class | string | — | Merged after hz-parallax-layer. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-parallax
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-axis | 'y' | 'x' | Which axis of the nearest scroller drives the drift. Stamped for both values. Default y — the page's vertical scroll. x tracks the band's horizontal crossing, for a band used as a panel inside a HorizontalScroll. |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-parallax-x | <length> — default 0 | Total horizontal travel of one layer across the band's pass through the viewport. Set it in your own CSS (and omit the x prop) to change travel per breakpoint. Negative drifts the other way. |
--hz-parallax-y | <length> — default 0 | Total vertical travel. Same rules as --hz-parallax-x. |
--hz-parallax-z | <number> — default -1 | The layer's z-index inside the band. The default sits it behind the band's content; 1 puts it in front. |
--hz-parallax-range | <animation-range> — default cover | Which part of the band's pass through the viewport the drift is spread over. cover is the whole pass; entry, exit, and contain narrow it. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-parallax-layer | child element | One drifting layer: absolutely positioned, sized to the band plus its own travel so the edges stay covered, decorative (aria-hidden) and click-through by default. |
Accessibility
Every layer is aria-hidden and pointer-events: none by default. Layers are decorative, so put buttons and links in a plain, non-layer child of the band instead.
prefers-reduced-motion: reduce removes the drift entirely, with no opt-out. Scroll-triggered parallax is exactly the motion WCAG 2.3.3 asks you to let people turn off, and this component treats none of it as essential. A browser without scroll-driven animation support shows the same still composition, with no polyfill and no console noise. Nothing moves until the visitor scrolls, so there is no automatic motion to satisfy 2.2.2 either. axis="x" follows the same reduced-motion rule: a horizontal band goes just as still.
The component reorders nothing and adds no role, tabindex, or live region. Reading and focus order come from as and your own content. It also contributes no color, so text sitting over a moving layer still has to meet contrast on its own.