Skip to content

Component

parallax

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and parallax.css. No scripts.

Source

Copy both files, or run npx nojsui add parallax.

Source for parallax
<!-- .sk-parallax — a hero band whose backdrop drifts slower than the page.

     Three elements. The band clips, the backdrop moves, the content does not.

     The backdrop is a plain element with a background, so swapping the demo's
     CSS gradient for a photo is one declaration on one element:
       .sk-parallax__backdrop { background-image: url(hero.jpg); }
     It is empty and decorative — the heading and text live in __content, which
     is a sibling, so nothing here depends on the backdrop being announced. -->
<div class="sk-parallax__scroller">
  <p class="sk-parallax__hint">Scroll down ↓</p>

  <header class="sk-parallax">
    <div class="sk-parallax__backdrop"></div>

    <div class="sk-parallax__content">
      <h3 class="sk-parallax__title">Depth, with no script</h3>
      <p class="sk-parallax__lede">
        The backdrop lags the page on a view() timeline. Scroll past and back —
        it tracks in both directions, because it is reading the scroll position
        rather than replaying an animation.
      </p>
    </div>
  </header>

  <p class="sk-parallax__note">
    In Firefox, and under <code>prefers-reduced-motion: reduce</code>, the
    backdrop simply sits still. A hero that does not drift is just a hero, so
    nothing is hidden and nothing degrades — unlike the reading progress bar,
    which removes itself rather than show a readout it cannot update.
  </p>

  <p class="sk-parallax__note">
    The layer is inset by exactly the distance it travels, so it can never pull
    away from an edge and reveal a gap. One number drives the motion and the
    slack together.
  </p>
</div>
/* .sk-parallax — a hero band whose backdrop drifts slower than the page.

   A view() timeline ties the drift to where the band sits in the scrollport,
   so there is no listener and no script. Two things in this file are not
   obvious and both fail silently:

   1. `overflow: clip`, NEVER `overflow: hidden`. `hidden` makes the band a
      scroll container, and view() binds to the nearest ancestor scroll
      container — so the band would capture its own backdrop's timeline and
      nothing would ever move. `clip` clips without becoming one. reveal.css
      documents the mirror image: a wrapper that IS a scroll container but does
      not overflow leaves the timeline inactive.

   2. The slack is derived from the drift. A layer that travels N pixels needs
      N pixels of spare on each side or it pulls off an edge and shows a gap —
      intermittently, at some viewport heights only. Insetting the backdrop by
      -distance makes its size implied by the same number that animates it, so
      there is no second value to forget.

   Specs:
   - view() ................. https://drafts.csswg.org/scroll-animations-1/#view-notation
   - animation-range ........ https://drafts.csswg.org/scroll-animations-1/#animation-range
   - overflow: clip ......... https://drafts.csswg.org/css-overflow-3/#valdef-overflow-clip */

/* Public theme knobs (ADR 0011). Read, never declared.

   The backdrop default is a gradient built entirely from tokens, and the
   second stop is chosen so contrast is provable rather than eyeballed: mixing
   the accent toward --sk-color-text moves it AWAY from --sk-color-on-accent in
   both schemes (text is near-black on light, near-white on dark, and on-accent
   is the reverse). check-contrast.mjs already holds on-accent against accent
   at 4.5:1, so every stop along this gradient is at least that far apart. */
.sk-parallax {
  --_distance: var(--sk-parallax-distance, var(--sk-space-2xl));
  --_backdrop: var(
    --sk-parallax-backdrop,
    linear-gradient(
      to bottom,
      var(--sk-color-accent),
      color-mix(in oklab, var(--sk-color-accent), var(--sk-color-text) 25%)
    )
  );
  --_min-block-size: var(--sk-parallax-min-block-size, 20rem);
  --_radius: var(--sk-parallax-radius, var(--sk-radius-lg));
  --_content-fg: var(--sk-parallax-content-fg, var(--sk-color-on-accent));
  --_range-start: var(--sk-parallax-range-start, cover 0%);
  --_range-end: var(--sk-parallax-range-end, cover 100%);

  position: relative;

  /* See note 1 in the header. This must not become `hidden`. */
  overflow: clip;
  display: grid;
  align-content: center;
  min-block-size: var(--_min-block-size);
  padding: var(--sk-space-xl);
  border-radius: var(--_radius);
  color: var(--_content-fg);
}

/* Painted unconditionally, OUTSIDE @supports. A hero that does not drift is
   just a hero, so the static state is the complete design and there is nothing
   to hide in an engine without view(). (progress does the opposite, because a
   progress track that can never fill is a lie rather than a still image.) */
.sk-parallax__backdrop {
  position: absolute;

  /* See note 2 in the header: inset by exactly the travel, so the size is
     implied by the same number that animates it. Logical, so the slack follows
     the writing mode. */
  inset-block: calc(-1 * var(--_distance));
  inset-inline: 0;
  background: var(--_backdrop);
  background-size: cover;
}

.sk-parallax__content {
  /* Above the absolutely-positioned backdrop without a z-index: a positioned
     element paints over a non-positioned one, and both are in flow order. */
  position: relative;
  display: grid;
  gap: var(--sk-space-xs);
}

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .sk-parallax__backdrop {
      /* Longhands, not the `animation` shorthand: the shorthand resets
         animation-duration to 0s and a scroll-driven animation with zero
         duration finishes instantly, pinning the backdrop at its end offset.
         `auto` hands the timing to the timeline. */
      animation-name: sk-parallax-drift;
      animation-duration: auto;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: var(--_range-start) var(--_range-end);
    }

    /* Physical, and correct rather than a rule-3 violation: the drift has to
       follow the scroll direction, and view() tracks the block axis of the
       scrollport. Starting high and ending low means the backdrop moves DOWN
       relative to the band as the page scrolls up — so in absolute terms it
       travels less than the band does, which is the lag the effect is made of.

       Unlike reveal, neither keyframe touches opacity: this animation never
       hides content, so there is nothing here that can leave the band blank in
       an engine that skips it. */
    @keyframes sk-parallax-drift {
      from {
        translate: 0 calc(-1 * var(--_distance));
      }

      to {
        translate: 0 var(--_distance);
      }
    }
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The band above is the component. Everything below is the demo's page around
   it — delete it when copying .sk-parallax into your own markup.
--------------------------------------------------------------------------- */

.sk-parallax__title {
  font-size: var(--sk-text-xl);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-parallax__lede {
  max-inline-size: 34rem;
  font-size: var(--sk-text-sm);
}

.sk-parallax__scroller {
  display: flex;
  flex-direction: column;
  gap: var(--sk-space-lg);
  padding: var(--sk-space-md);

  /* The demo must overflow its frame or the timeline is inactive and the band
     never drifts — the same trap progress.css documents. A relative unit keeps
     that true at any frame size. */
  min-block-size: 200vb;
}

.sk-parallax__hint,
.sk-parallax__note {
  max-inline-size: 34rem;
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

.sk-parallax__hint {
  color: var(--sk-color-text-subtle);
  font-size: var(--sk-text-xs);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
overflow: clipwidely90908116908116
color-mix()widely11111111316.211111316.2
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Scroll-driven animationslimited1151152611526

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.

KeyBehaviour
TabPasses through to whatever you put in __content
Any other keyNo 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 usedBaseline statusBehavior without it
overflow: clipwidelyThe 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()widelyOnly 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 FirefoxThe 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

PropertyDefaultControls
--sk-parallax-distance--sk-space-2xlHow far the backdrop drifts — and, by construction, the slack it gets
--sk-parallax-backdropgradient from --sk-color-accentThe backdrop layer’s background; set url(...) for a photo
--sk-parallax-min-block-size20remHeight of the band
--sk-parallax-radius--sk-radius-lgCorner radius of the band
--sk-parallax-content-fg--sk-color-on-accentForeground colour over the backdrop
--sk-parallax-range-startcover 0%Where the drift begins, as an animation-range
--sk-parallax-range-endcover 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).