Skip to content
zedkit
@zedtech/zedkit-ui

ScrollScenes

Pin one stage under the header while the page scrolls; scenes fade in and out in turn. Pure CSS, native scrolling.

alpha@zedtech/zedkit-ui

Wraps: none (CSS scroll-driven animation)

Step 1 of 3

Plan the week

Harbor Studio starts with one list: what has to ship, and who owns it.

Step 2 of 3

Track the work

Progress updates as people tick tasks, so nobody asks for a status report.

Step 3 of 3

Ship with confidence

Everything is green, the checklist is done: publish in one click.

View props table
Who is it for?
Developers telling a story in a few steps (one product shown in several looks, a feature tour, before/after) on a marketing or docs page.
When should I use it?
Two to eight (`SCROLL_SCENES_MAX`) short scenes that read well one at a time and where scrolling is the natural way to move on.
When should I pick something else?
Long content, forms or anything focusable inside a scene — invisible scenes stay in the accessibility tree, so keep scene visuals non-interactive (`aria-hidden` and `inert`); steps the user must pick themselves — use `Tabs` or `WizardSteps`.

Use it

Pinned feature tour
import { Heading, ProgressBar, ScrollScenes, Stack, Text } from "@zedtech/zedkit-ui";

const steps = [
  { id: "plan", title: "Plan the week", text: "One list: what has to ship, and who owns it.", value: 0 },
  { id: "track", title: "Track the work", text: "Progress updates as people tick tasks.", value: 55 },
  { id: "ship", title: "Ship with confidence", text: "Everything is green: publish in one click.", value: 100 },
];

// The caption says it all; the visual is decoration, hidden from assistive tech.
<ScrollScenes
  scenes={steps.map((step, index) => ({
    id: step.id,
    caption: (
      <Stack gap="sm">
        <Text variant="muted">Step {index + 1} of {steps.length}</Text>
        <Heading level={3}>{step.title}</Heading>
        <Text variant="muted">{step.text}</Text>
      </Stack>
    ),
    children: (
      <Stack aria-hidden="true" inert>
        <ProgressBar value={step.value} label="Week 43 progress" />
      </Stack>
    ),
  }))}
/>

Before and after

Before

One long form

Twelve fields on one screen: people left before the end.

After

Three short steps

The same questions, split in three steps with a progress bar.

import { Heading, ProgressBar, ScrollScenes, Stack, Text } from "@zedtech/zedkit-ui";

// Two scenes, a shorter scroll per scene (default 60svh).
<ScrollScenes
  className="[--zk-scenes-step:45svh]"
  scenes={[
    {
      id: "before",
      caption: <Heading level={3}>Before: one long form</Heading>,
      children: (
        <Stack aria-hidden="true" inert>
          <ProgressBar value={18} label="Completed sign-ups" />
          <Text variant="muted">18% of visitors finished the form.</Text>
        </Stack>
      ),
    },
    {
      id: "after",
      caption: <Heading level={3}>After: three short steps</Heading>,
      children: (
        <Stack aria-hidden="true" inert>
          <ProgressBar value={64} label="Completed sign-ups" />
          <Text variant="muted">64% of visitors finished the steps.</Text>
        </Stack>
      ),
    },
  ]}
/>

Same product in three looks

Look 1 of 3

Mist

The stock look: quiet chrome and Inter. Good when the product should stay out of the way.

Look 2 of 3

Press

Editorial type. Same buttons and fields, a reading product.

Look 3 of 3

Tide

Warm color and a bit more space. Same screen, a different shop.

import { Heading, ScrollScenes, Stack, Text, ThemeScope } from "@zedtech/zedkit-ui";

const looks = [
  { id: "mist", title: "Mist", text: "The stock look: quiet chrome, Inter." },
  { id: "press", title: "Press", text: "Editorial type for a reading product." },
  { id: "tide", title: "Tide", text: "Warm colour and a bit more space." },
];

<ScrollScenes
  scenes={looks.map((look) => ({
    id: look.id,
    caption: (
      <Stack gap="sm">
        <Heading level={3}>{look.title}</Heading>
        <Text variant="muted">{look.text}</Text>
      </Stack>
    ),
    children: (
      <ThemeScope identity={look.id} aria-hidden="true" inert>
        <Text className="font-semibold">Harbor Studio</Text>
        <Text variant="muted">Project tracker</Text>
      </ThemeScope>
    ),
  }))}
/>

Pricing walkthrough

Plan 1 of 3

Solo

One person, one project. Enough to try the tracker on real work.

Plan 2 of 3

Crew

A small team, one workspace. Shared list, no extra tools.

Plan 3 of 3

Studio

Every workspace, with SSO. For a studio that already has clients.

import { Heading, ScrollScenes, Stack, Text } from "@zedtech/zedkit-ui";

const plans = [
  { id: "solo", title: "Solo", text: "One person, one project.", price: "$0" },
  { id: "crew", title: "Crew", text: "A small team, one workspace.", price: "$29" },
  { id: "studio", title: "Studio", text: "Every workspace, with SSO.", price: "$99" },
];

<ScrollScenes
  scenes={plans.map((plan) => ({
    id: plan.id,
    caption: (
      <Stack gap="sm">
        <Heading level={3}>{plan.title}</Heading>
        <Text variant="muted">{plan.text}</Text>
      </Stack>
    ),
    children: (
      <Stack aria-hidden="true" inert>
        <Text className="font-semibold">{plan.price}</Text>
        <Text variant="muted">per month</Text>
      </Stack>
    ),
  }))}
/>

Kit not installed yet?

This ships on npm only; there is no copy-source brick for it.

You get
Every component from one import, with the theme tokens included. Updates arrive with your package manager.
You need
  • React 19 and Tailwind CSS 4, for example a Next.js app created with the Tailwind option.
  1. Install

    Terminal
    pnpm add @zedtech/zedkit-ui @fontsource-variable/inter @fontsource-variable/space-grotesk
  2. Load the styles

    Tailwind does not scan node_modules, so the @source line points it at the kit's class names. The path is relative to this file.

    src/app/globals.cssReplace the contents
    @import "tailwindcss";
    @import "@zedtech/zedkit-ui/styles.css";
    @source "../../node_modules/@zedtech/zedkit-ui/src/**/*.{ts,tsx}";
    @source not "../../node_modules/@zedtech/zedkit-ui/src/**/*.test.*";
    
  3. Pick a look

    Set data-identity="glass" on <html> so the finished app matches the site. Fresh app: replace src/app/layout.tsx. Existing app: merge, and keep ThemeProvider and other providers.

    src/app/layout.tsxReplace the contents
    import "./globals.css";
    import "./fonts";
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="en" data-identity="glass">
          <body>{children}</body>
        </html>
      );
    }
    
  4. Load the fonts

    The Glass look uses Inter and Space Grotesk; these imports self-host them.

    src/app/fonts.tsCreate the file
    import "@fontsource-variable/inter";
    import "@fontsource-variable/space-grotesk";
    
  5. Import and render

    Pinned feature tour
    import { Heading, ProgressBar, ScrollScenes, Stack, Text } from "@zedtech/zedkit-ui";
    
    const steps = [
      { id: "plan", title: "Plan the week", text: "One list: what has to ship, and who owns it.", value: 0 },
      { id: "track", title: "Track the work", text: "Progress updates as people tick tasks.", value: 55 },
      { id: "ship", title: "Ship with confidence", text: "Everything is green: publish in one click.", value: 100 },
    ];
    
    // The caption says it all; the visual is decoration, hidden from assistive tech.
    <ScrollScenes
      scenes={steps.map((step, index) => ({
        id: step.id,
        caption: (
          <Stack gap="sm">
            <Text variant="muted">Step {index + 1} of {steps.length}</Text>
            <Heading level={3}>{step.title}</Heading>
            <Text variant="muted">{step.text}</Text>
          </Stack>
        ),
        children: (
          <Stack aria-hidden="true" inert>
            <ProgressBar value={step.value} label="Week 43 progress" />
          </Stack>
        ),
      }))}
    />
    

Package version 2.0.1.

Props

Prop

Type

Show inherited HTML attributes

Standard attributes from the underlying element. See MDN.

Prop

Type

Accessibility

Captions are real text in a fixed order and are never hidden (opacity only), so put the message in the caption and treat the visual as decoration: mark it aria-hidden="true" and inert so it adds no tab stop and no duplicate announcement. Under prefers-reduced-motion: reduce, in browsers without scroll timelines (Firefox, Safari before 26) and in very short windows, the scenes are plain blocks one under the other with the same content. A decorative progress rail is aria-hidden. Give each caption a heading one level under the section title so screen reader users can jump scene by scene.

Troubleshooting

Nothing is pinned — you need two to eight scenes, the browser needs `animation-timeline: view()` and the window at least 34rem tall; otherwise you get the stacked layout by design. Stage hides under a fixed header — set `--zk-scenes-top` to the header height. Scenes change too fast or slowly — set `--zk-scenes-step` (scroll distance per scene, default 60svh). Content cut at the bottom — the stage is one screen tall; shorten the scene or hide secondary parts on small screens. Pinning stops inside a box — a parent with `overflow: hidden` or `auto` becomes the scroll container; keep the component in the normal page flow. The scenes stack in your own preview — `prefers-reduced-motion: reduce` is on in the OS or the browser; this is the intended fallback.

On this page