Product interfaces · Original
Scroll showcase
A scroll-aware product scene that flattens into a detailed workspace dashboard.
Live preview
Try it in context
Customize preview
Source delivery
v0.3.0Usage & source
"use client";
import type { CSSProperties } from "react";
import { ScrollShowcase } from "./components/stepkit/ScrollShowcase";
import "./components/stepkit/styles.css";
import "./components/stepkit/showcase.css";
const themeStyle = {"--stepkit-bg":"#f7f7f8","--stepkit-surface":"#ffffff","--stepkit-surface-muted":"#f0f0f3","--stepkit-fg":"#17181c","--stepkit-muted":"#626572","--stepkit-border":"#e3e4e9","--stepkit-border-strong":"#c8cad3","--stepkit-code":"#f1f1f4","--stepkit-shadow":"0 20px 60px rgba(31, 26, 64, .10)","--stepkit-accent":"#b64e0d","--stepkit-accent-strong":"#8f3b08","--stepkit-accent-soft":"#fff0e6","--stepkit-accent-on-soft":"#8f3b08","--stepkit-accent-text":"#ffffff","--stepkit-error":"#b43145","--stepkit-placeholder":"#626572","--stepkit-radius":"16px","--stepkit-space":"16px","--stepkit-control-height":"44px","--stepkit-font-sans":"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"} as CSSProperties;
export default function Example() {
return (
<div className="stepkit-theme" data-stepkit-preset="modern" style={themeStyle}>
<ScrollShowcase
headline={"Make room for your next big idea."}
description={"A composed workspace that turns a busy product into a clear place to think."}
paused={false}
/>
</div>
);
}Dependencies
[email protected] [email protected]
/*
* Stepkit 0.3.0
* Copyright (c) 2026 Vladislav Stepanov
*
* Original Stepkit portions in this release are licensed under the MIT License. Adapted portions identify their upstream licence and attribution in the copied source and in the entry-specific upstream notice files.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
"use client";
import { useEffect, useId, useState, type CSSProperties } from "react";
import "./styles.css";
import "./showcase.css";
export interface ScrollShowcaseProject {
id: string;
name: string;
status: "Live" | "Review" | "Draft";
updated?: string;
}
export interface ScrollShowcaseProps {
headline?: string;
description?: string;
progress?: number;
defaultProgress?: number;
projects?: ScrollShowcaseProject[];
onProgressChange?: (progress: number) => void;
onProjectSelect?: (project: ScrollShowcaseProject) => void;
onFilterChange?: (active: boolean) => void;
onAddProject?: () => void;
paused?: boolean;
className?: string;
}
const defaultProjects: ScrollShowcaseProject[] = [
{ id: "atlas", name: "Atlas workspace", status: "Live", updated: "Updated 8m ago" },
{ id: "signal", name: "Signal library", status: "Review", updated: "Updated 42m ago" },
{ id: "ledger", name: "Ledger redesign", status: "Draft", updated: "Updated yesterday" }
];
const clamp = (value: number) => Math.max(0, Math.min(1, value));
export function ScrollShowcase({
headline = "Make room for your next big idea.",
description = "A composed workspace that turns a busy product into a clear place to think.",
progress,
defaultProgress = 0,
projects = defaultProjects,
onProgressChange,
onProjectSelect,
onFilterChange,
onAddProject,
paused = false,
className = ""
}: ScrollShowcaseProps) {
const headingId = useId();
const chartId = useId().replace(/:/g, "");
const [internalProgress, setInternalProgress] = useState(clamp(defaultProgress));
const [projectRows, setProjectRows] = useState(projects);
const [selectedProject, setSelectedProject] = useState(projects[0]?.id ?? "");
const [filterActive, setFilterActive] = useState(false);
const [reduced, setReduced] = useState(false);
const isControlled = typeof progress === "number";
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(query.matches);
update();
query.addEventListener?.("change", update);
return () => query.removeEventListener?.("change", update);
}, []);
useEffect(() => {
if (isControlled || paused || reduced) return;
let frame = 0;
const update = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const element = document.getElementById(headingId)?.closest("section");
if (!element) return;
const rect = element.getBoundingClientRect();
const distance = window.innerHeight + rect.height;
const next = clamp((window.innerHeight - rect.top) / Math.max(distance, 1));
setInternalProgress(next);
onProgressChange?.(next);
});
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [headingId, isControlled, onProgressChange, paused, reduced]);
const currentProgress = paused || reduced ? clamp(progress ?? defaultProgress) : clamp(progress ?? internalProgress);
const visibleProjects = filterActive ? projectRows.filter((project) => project.status === "Live") : projectRows;
const tilt = (1 - currentProgress) * 13;
const yaw = (1 - currentProgress) * -8;
const scale = .88 + currentProgress * .12;
const laptopStyle = { transform: `perspective(1500px) rotateX(${tilt}deg) rotateY(${yaw}deg) scale(${scale})` } as CSSProperties;
const selectProject = (project: ScrollShowcaseProject) => {
setSelectedProject(project.id);
onProjectSelect?.(project);
};
const toggleFilter = () => {
const next = !filterActive;
setFilterActive(next);
onFilterChange?.(next);
};
const addProject = () => {
if (onAddProject) { onAddProject(); return; }
const draft: ScrollShowcaseProject = { id: "new-project", name: "New project", status: "Draft", updated: "Ready to shape" };
setProjectRows((current) => current.some((project) => project.id === draft.id) ? current : [draft, ...current]);
setSelectedProject(draft.id);
};
return (
<section className={`stepkit stepkit-showcase stepkit-scroll-showcase${className ? ` ${className}` : ""}`} data-paused={paused} aria-labelledby={headingId}>
<header className="stepkit-scroll-showcase__header">
<span className="stepkit-scroll-showcase__kicker">Container / scroll showcase</span>
<h2 className="stepkit-scroll-showcase__title" id={headingId}>{headline}</h2>
<p className="stepkit-scroll-showcase__description">{description}</p>
</header>
<div className="stepkit-scroll-showcase__viewport" aria-label="Product workspace preview">
<div className="stepkit-scroll-showcase__laptop" style={laptopStyle}>
<div className="stepkit-scroll-showcase__screen">
<div className="stepkit-scroll-showcase__toolbar"><span className="stepkit-scroll-showcase__traffic" aria-hidden="true"><i /><i /><i /></span><strong>Northstar / Workspace</strong><span>Synced moments ago</span></div>
<div className="stepkit-scroll-showcase__dashboard">
<aside className="stepkit-scroll-showcase__nav" aria-label="Workspace sections"><strong className="stepkit-scroll-showcase__nav-brand">northstar</strong><small>Workspace</small><ul><li data-active="true">Overview</li><li>Projects</li><li>Signals</li><li>Members</li></ul><span className="stepkit-scroll-showcase__nav-footer">12 seats · shared</span></aside>
<div className="stepkit-scroll-showcase__workspace">
<div className="stepkit-scroll-showcase__workspace-head"><div><h2>Good morning, Mira</h2><p>Here is the shape of your work this week.</p></div><div className="stepkit-scroll-showcase__workspace-actions"><button type="button" aria-label="Filter workspace" aria-pressed={filterActive} onClick={toggleFilter}>Filter{filterActive ? "ed" : ""}</button><button type="button" aria-label="Add project" onClick={addProject}>+</button></div></div>
<div className="stepkit-scroll-showcase__chart"><div className="stepkit-scroll-showcase__chart-head"><strong>Focus time</strong><span>+18.4%</span></div><svg viewBox="0 0 540 105" role="img" aria-label="Focus time rising over six weeks"><path d="M0 84H540M0 52H540M0 20H540" fill="none" stroke="#30353e" strokeWidth="1" /><path d="M0 82 C45 78 55 70 92 74 S145 48 182 58 S230 62 270 42 S320 55 356 34 S420 44 458 21 S510 29 540 10" fill="none" stroke="var(--stepkit-accent, #78d9b1)" strokeWidth="3" /><path d="M0 82 C45 78 55 70 92 74 S145 48 182 58 S230 62 270 42 S320 55 356 34 S420 44 458 21 S510 29 540 10 V105 H0Z" fill={`url(#${chartId})`} opacity=".18" /><defs><linearGradient id={chartId} x1="0" x2="0" y1="0" y2="1"><stop stopColor="var(--stepkit-accent, #78d9b1)" /><stop offset="1" stopColor="var(--stepkit-accent, #78d9b1)" stopOpacity="0" /></linearGradient></defs></svg></div>
<div className="stepkit-scroll-showcase__rows">{visibleProjects.slice(0, 3).map((project) => <button className="stepkit-scroll-showcase__row" data-active={selectedProject === project.id} type="button" key={project.id} onClick={() => selectProject(project)}><strong>{project.name}</strong><span>{project.updated ?? "Recently updated"}</span><span className="stepkit-scroll-showcase__status">{project.status}</span></button>)}</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}
export default ScrollShowcase;
components/ScrollShowcase.tsxcomponents/showcase.csscomponents/styles.cssInstall
Use the registry command or download the complete source archive.
npx [email protected] add https://vstepanov.com/stepkit/r/scroll-showcase.jsonIntegration prompt
Install Stepkit Scroll showcase 0.3.0 from the registry and preserve the included MIT notice. npx [email protected] add https://vstepanov.com/stepkit/v0.3.0/r/scroll-showcase.json Install the tested runtime dependencies exactly: npm install [email protected] [email protected] Keep the component source, shared styles, NOTICE.md, and provenance files together. Apply the theme tokens on a wrapper rather than passing theme keys as component props. Usage: ```tsx "use client"; import type { CSSProperties } from "react"; import { ScrollShowcase } from "./components/stepkit/ScrollShowcase"; import "./components/stepkit/styles.css"; import "./components/stepkit/showcase.css"; const themeStyle = {"--stepkit-bg":"#f7f7f8","--stepkit-surface":"#ffffff","--stepkit-surface-muted":"#f0f0f3","--stepkit-fg":"#17181c","--stepkit-muted":"#626572","--stepkit-border":"#e3e4e9","--stepkit-border-strong":"#c8cad3","--stepkit-code":"#f1f1f4","--stepkit-shadow":"0 20px 60px rgba(31, 26, 64, .10)","--stepkit-accent":"#6950df","--stepkit-accent-strong":"#5137c7","--stepkit-accent-soft":"#eeeafd","--stepkit-accent-on-soft":"#5137c7","--stepkit-accent-text":"#ffffff","--stepkit-error":"#b43145","--stepkit-placeholder":"#626572","--stepkit-radius":"16px","--stepkit-space":"16px","--stepkit-control-height":"44px","--stepkit-font-sans":"Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif"} as CSSProperties; export default function Example() { return ( <div className="stepkit-theme" data-stepkit-preset="modern" style={themeStyle}> <ScrollShowcase headline={"Make room for your next big idea."} description={"A composed workspace that turns a busy product into a clear place to think."} paused={false} /> </div> ); } ``` Selected bounded settings: {"headline":"Make room for your next big idea.","description":"A composed workspace that turns a busy product into a clear place to think.","paused":false}. Selected theme: {"mode":"light","accent":"violet","radius":"soft","density":"comfortable","preset":"modern"}.
Props
| Name | Type | Default | Description |
|---|---|---|---|
headline | string | "Make room for your next big idea." | Editorial heading. |
description | string | "A composed workspace that turns a busy product into a clear place to think." | Supporting copy. |
progress | number | undefined | Controlled 0–1 scroll progress. |
projects | ScrollShowcaseProject[] | default projects | Dashboard project rows. |
onProjectSelect | (project: ScrollShowcaseProject) => void | undefined | Called after a project row is selected. |
paused | boolean | false | Freezes scroll response and preserves the resting pose. |
Accessibility & limits
- The dashboard is composed from readable native text and controls.
- Scroll response is disabled for reduced-motion preferences and paused mode.
- The laptop scene is decorative around the labelled heading and project controls.
Limitations
- The component observes the page scroll position when progress is uncontrolled; pass progress for an embedded scroll container.
Provenance
Original source under the MIT licence.
Original Stepkit implementation.
Required notices: NOTICE.md, PROVENANCE-stepkit-scroll-showcase.md