Sideways scroll
A section that moves sideways while the page scrolls down. It stays in place until its row has passed, a line and a counter show how far you are. On phones each variant has its own layout, without JavaScript the row scrolls by hand.
One of 4 components in Scroll stories
Variants
Details
What Sideways scroll brings along, read from the registry and the source.
- Category
- Scroll stories
- Area
- Marketing
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/sideways-scroll.tsx
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SidewaysScrollProps interface from components/ui/sideways-scroll.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalSidewaysScrollVariantcardstimelinegallerychaptersproducts
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringitemsrequiredSidewaysItem[]actionoptional{ label: string; href: string }backdropoptionalstringgallery: a large word behind the pictures
Built-in texts
Sideways scroll writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| region | English Scrollable row | German Seitlich scrollbare Reihe |
| of | English of | German von |
| more | English Learn more | German Mehr erfahren |
| all | English See the whole collection | German Ganze Kollektion ansehen |
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role running.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 395 lines
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { useScrollFade } from '@/components/ui/motion';
import { seg, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A section that moves sideways while the page scrolls down. The section sticks for as long as
* its row needs to pass, one pixel of scrolling moves the row by one pixel, a line and a counter
* show how far you are. From 768 px on. On phones every variant has its own layout (a list, a
* timeline downwards or a swipe row). Without JavaScript and with reduced motion the row is a
* normal row you scroll sideways with touch, trackpad or keyboard.
*
* cards service or feature cards with an icon and a number
* timeline years on a line that fills while you pass them
* gallery pictures of different widths with a large word gliding slower behind them
* chapters wide panels, one chapter each, text beside a picture
* products a shop row with prices and a closing tile to the full collection
*/
export type SidewaysScrollVariant = 'cards' | 'timeline' | 'gallery' | 'chapters' | 'products';
export interface SidewaysItem {
title: string;
text?: string;
/** Small line above the title: category, place, a date */
eyebrow?: string;
icon?: IconName;
media?: React.ReactNode;
/** timeline: the year or date in large figures */
year?: string;
/** products: the price as written, e.g. "€34" */
price?: string;
/** products: a short label on the picture, e.g. "New" */
tag?: string;
href?: string;
}
export interface SidewaysScrollProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: SidewaysScrollVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
items: SidewaysItem[];
action?: { label: string; href: string };
/** gallery: a large word behind the pictures */
backdrop?: string;
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { region: 'Scrollable row', of: 'of', more: 'Learn more', all: 'See the whole collection' },
de: { region: 'Seitlich scrollbare Reihe', of: 'von', more: 'Mehr erfahren', all: 'Ganze Kollektion ansehen' },
};
const pad = (n: number) => String(n).padStart(2, '0');
/** Where an item counts as reached: its left edge passes this share of the window width */
const LINE = 0.42;
interface Mark {
a: number;
b: number;
}
/**
* Measures the row once it is live (and on resize): how far it moves and at which progress each
* item reaches the reading line. No measuring while scrolling.
*/
function useRow(live: boolean) {
const row = useRef<HTMLDivElement>(null);
const [dist, setDist] = useState(0);
const [marks, setMarks] = useState<Mark[]>([]);
useEffect(() => {
const el = row.current;
if (!el || !live) {
setDist(0);
setMarks([]);
return;
}
const measure = () => {
const items = Array.from(el.querySelectorAll<HTMLElement>(':scope > [data-item]'));
const last = items[items.length - 1];
if (!last) return;
const padEnd = parseFloat(getComputedStyle(el).paddingRight) || 0;
const d = Math.max(0, Math.round(last.offsetLeft + last.offsetWidth + padEnd - el.clientWidth));
const line = el.clientWidth * LINE;
setDist(d);
// Items near the end never reach the line, so the marks are squeezed until the last one is reached at 92 %
const raw = items.map((it) => (it.offsetLeft - line) / Math.max(1, d));
const s = Math.min(1, 0.92 / Math.max(0.0001, raw[raw.length - 1]));
setMarks(items.map((it, i) => {
// already past the line at the start: always reached
if (raw[i] <= 0) return { a: -1, b: -0.999 };
const a = Math.min(1, raw[i] * s);
return { a: round(a, 4), b: round(Math.min(1, Math.max(a + 0.0001, a + (it.offsetWidth / Math.max(1, d)) * s)), 4) };
}));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [live]);
return { row, dist, marks };
}
/** 0 → 1 while --p runs through an item's mark. Finished without JavaScript. */
const through = (m?: Mark) => (m ? seg(m.a, m.b) : '1');
function Header({ eyebrow, title, description, action, aside }: Pick<SidewaysScrollProps, 'eyebrow' | 'title' | 'description' | 'action'> & { aside?: React.ReactNode }) {
return (
<div className="kit-container">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between md:gap-10">
<Reveal className="max-w-[40rem]">
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && <p className="mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground">{description}</p>}
</Reveal>
{(aside || action) && (
<div className="flex shrink-0 items-center gap-5">
{aside}
{action && (
<Button href={action.href} variant="secondary" icon="arrow-right">
{action.label}
</Button>
)}
</div>
)}
</div>
</div>
);
}
/* ---------- Items ---------- */
function Card({ it, i, mark }: { it: SidewaysItem; i: number; mark?: Mark }) {
return (
<article className="group relative flex h-full flex-col justify-between gap-10 overflow-hidden rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,transform] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:border-border-strong motion-reduce:transition-none sm:p-7">
<div className="flex items-start justify-between">
<span className="flex size-11 items-center justify-center rounded-xl border border-border bg-surface text-primary transition-colors duration-500 group-hover:bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface))]">
<Icon name={it.icon ?? 'sparkle'} size={20} />
</span>
{/* The number lights up when the card reaches the reading line */}
<span className="relative text-[13px] font-medium tabular-nums text-subtle-foreground">
{pad(i + 1)}
<span aria-hidden data-p className="absolute inset-0 text-primary" style={{ opacity: through(mark) }}>
{pad(i + 1)}
</span>
</span>
</div>
<div>
{it.eyebrow && <p className="text-[12.5px] font-medium text-muted-foreground">{it.eyebrow}</p>}
<h3 className="mt-2 text-balance text-[1.5rem] font-semibold leading-tight tracking-[-0.015em] text-foreground">{it.title}</h3>
{/* Room for three lines in the row, so the titles of all cards stand on one line */}
{it.text && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground md:min-h-[4.875em]">{it.text}</p>}
{it.href && (
<a href={it.href} className="mt-5 inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium text-foreground after:absolute after:inset-0 focus-visible:outline-none [&:focus-visible]:after:rounded-2xl [&:focus-visible]:after:outline-2 [&:focus-visible]:after:outline-solid [&:focus-visible]:after:-outline-offset-2 [&:focus-visible]:after:outline-primary">
{it.title}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover:translate-x-0.5" />
</a>
)}
</div>
</article>
);
}
function Year({ it, i, n, mark, live }: { it: SidewaysItem; i: number; n: number; mark?: Mark; live: boolean }) {
return (
<div className="relative pl-8 md:pl-0 md:pt-10">
{/* The line: downwards on phones, across from 768 px. Each item draws its own piece. */}
<span aria-hidden className={cn('absolute left-[7px] top-2 w-px bg-border md:left-0 md:right-0 md:top-[7px] md:h-px md:w-auto', i === n - 1 ? 'bottom-0 md:right-auto md:w-12' : '-bottom-10 md:-right-0')} />
<span
aria-hidden
data-p
className={cn('absolute left-[7px] top-2 w-px origin-top bg-primary md:left-0 md:right-0 md:top-[7px] md:h-px md:w-auto md:origin-left', i === n - 1 ? 'bottom-0 md:right-auto md:w-12' : '-bottom-10 md:-right-0')}
style={{ transform: live ? `scaleX(${through(mark)})` : undefined }}
/>
<span aria-hidden className="absolute left-0 top-0 flex size-[15px] items-center justify-center rounded-full border border-border-strong bg-background">
<span data-p className="size-[7px] rounded-full bg-primary" style={{ opacity: live ? `calc(0.15 + ${through(mark)} * 0.85)` : undefined }} />
</span>
<p data-p className="text-[clamp(2.75rem,2rem+3vw,5.5rem)] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground" style={{ opacity: live ? `calc(0.35 + ${through(mark)} * 0.65)` : undefined }}>
{it.year}
</p>
<h3 className="mt-4 text-balance text-[17px] font-semibold text-foreground">{it.title}</h3>
{/* Three lines of room in the row, so the pictures below stand on one line */}
{it.text && <p className="mt-2 max-w-[22rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground md:min-h-[4.875em]">{it.text}</p>}
{it.media && <div className="mt-5 h-36 max-w-[22rem] overflow-hidden rounded-lg bg-surface md:h-[min(10rem,20svh)] [&>*]:size-full">{it.media}</div>}
</div>
);
}
const GALLERY_W = ['md:w-[26rem]', 'md:w-[19rem]', 'md:w-[32rem]', 'md:w-[22rem]', 'md:w-[28rem]', 'md:w-[20rem]'];
function Picture({ it, i }: { it: SidewaysItem; i: number }) {
return (
<figure className="group">
<div className="relative h-[min(26rem,62vw)] overflow-hidden rounded-xl bg-surface md:h-[min(24rem,40svh)] [&>*]:size-full [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">{it.media}</div>
<figcaption className="mt-4 flex items-baseline justify-between gap-4">
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold text-foreground">{it.title}</span>
{it.text && <span className="block truncate text-[13.5px] text-muted-foreground">{it.text}</span>}
</span>
<span className="shrink-0 text-[12.5px] tabular-nums text-subtle-foreground">{it.eyebrow ?? pad(i + 1)}</span>
</figcaption>
</figure>
);
}
function Chapter({ it, i, t }: { it: SidewaysItem; i: number; t: (typeof TEXTS)['en'] }) {
return (
<article className="grid h-full grid-cols-1 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
<div className="flex flex-col justify-between gap-8 p-6 sm:p-8 lg:p-10">
<p className="flex items-center gap-3 text-[13px] font-medium tabular-nums text-muted-foreground">
<span className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{pad(i + 1)}</span>
<span aria-hidden className="h-px w-8 bg-border-strong" />
{it.eyebrow && <span className="text-foreground">{it.eyebrow}</span>}
</p>
<div>
<h3 className="text-balance text-[clamp(1.625rem,1.2rem+1.3vw,2.375rem)] font-semibold leading-[1.1] tracking-[-0.025em] text-foreground">{it.title}</h3>
{it.text && <p className="mt-4 max-w-[28rem] text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{it.text}</p>}
{it.href && (
<a href={it.href} className="-mx-2 mt-6 inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 text-[14px] font-medium text-foreground hover:underline hover:underline-offset-4 focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-primary">
{t.more}
<span className="sr-only">, {it.title}</span>
<Icon name="arrow-right" size={15} />
</a>
)}
</div>
</div>
{it.media && <div className="relative min-h-[14rem] overflow-hidden border-t border-border bg-surface lg:border-l lg:border-t-0 [&>*]:absolute [&>*]:inset-0 [&>*]:size-full">{it.media}</div>}
</article>
);
}
function Product({ it }: { it: SidewaysItem }) {
return (
<article className="group relative">
<div className="relative aspect-[4/5] overflow-hidden rounded-xl bg-surface [&>*]:size-full [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover:[&>*]:scale-[1.04] motion-reduce:[&>*]:transition-none">
{it.media}
{it.tag && <span className="absolute left-3 top-3 !size-auto rounded-full bg-background px-2.5 py-1 text-[11.5px] font-medium text-foreground shadow-[var(--shadow-sm)]">{it.tag}</span>}
</div>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-[14.5px] font-medium text-foreground">
<a href={it.href ?? '#'} className="after:absolute after:inset-0 focus-visible:outline-none [&:focus-visible]:after:rounded-xl [&:focus-visible]:after:outline-2 [&:focus-visible]:after:outline-solid [&:focus-visible]:after:outline-offset-4 [&:focus-visible]:after:outline-primary">
{it.title}
</a>
</h3>
{it.eyebrow && <p className="truncate text-[13px] text-muted-foreground">{it.eyebrow}</p>}
</div>
{it.price && <p className="shrink-0 text-[14.5px] font-semibold tabular-nums text-foreground">{it.price}</p>}
</div>
</article>
);
}
/* ---------- Section ---------- */
const ITEM_W: Record<SidewaysScrollVariant, string> = {
cards: 'w-full md:h-[min(22rem,48svh)] md:w-[21rem] lg:w-[23rem]',
timeline: 'w-full md:w-[21rem] md:pr-10 lg:w-[24rem]',
gallery: 'w-[78%] shrink-0 snap-start sm:w-[22rem]',
chapters: 'w-full md:h-[min(30rem,60svh)] md:w-[min(82vw,44rem)] lg:w-[min(72vw,58rem)]',
products: 'w-[62%] shrink-0 snap-start sm:w-[15rem] md:w-[16rem]',
};
/** On phones: a list downwards (cards, timeline, chapters) or a swipe row (gallery, products) */
const PHONE: Record<SidewaysScrollVariant, 'list' | 'row'> = { cards: 'list', timeline: 'list', gallery: 'row', chapters: 'list', products: 'row' };
/** A section that scrolls sideways while the page scrolls down. */
export function SidewaysScroll({ variant = 'cards', eyebrow, title, description, items, action, backdrop, labels, className, ...rest }: SidewaysScrollProps) {
const t = useTexts(TEXTS, labels);
const live = useLive('(min-width: 768px)');
const { row, dist, marks } = useRow(live);
const running = live && dist > 0;
const n = items.length;
const [active, setActive] = useState(0);
// The counter walks evenly from the first to the last item
const back = useRef<HTMLParagraphElement>(null);
// The row and the word behind it move by a transform written straight onto them. A custom
// property on the row would restyle every picture in it on every frame.
const onProgress = useCallback(
(p: number) => {
setActive(Math.round(p * (n - 1)));
if (row.current) row.current.style.transform = `translate3d(${round(-p * dist, 1)}px, 0, 0)`;
if (back.current) back.current.style.transform = `translate3d(${round(-p * dist * 0.35, 1)}px, 0, 0)`;
},
[n, dist, row],
);
const track = useSceneProgress<HTMLDivElement>(running, { onProgress, self: false });
useEffect(() => {
if (running) return;
row.current?.style.removeProperty('transform');
back.current?.style.removeProperty('transform');
}, [running, row]);
const [fadeRef, fade] = useScrollFade<HTMLDivElement>();
const phone = PHONE[variant];
// Tab into an item that sits outside the window: scroll the page to the progress that shows it
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
if (!running || !track.current) return;
const item = (e.target as HTMLElement).closest<HTMLElement>('[data-item]');
if (!item) return;
const i = Number(item.dataset.item);
const m = marks[i];
if (!m) return;
const top = track.current.getBoundingClientRect().top + window.scrollY;
const travel = track.current.offsetHeight - window.innerHeight;
window.scrollTo({ top: Math.round(top + Math.max(0, Math.min(1, m.a + 0.02)) * travel), behavior: 'instant' as ScrollBehavior });
};
const counter = (
<div className={cn('hidden items-center gap-4 md:flex', !running && 'md:hidden')} aria-hidden={!running}>
<span className="w-16 text-[12.5px] font-medium tabular-nums text-muted-foreground">
{pad(active + 1)} / {pad(n)}
</span>
<span className="relative h-0.5 w-32 overflow-hidden rounded-full bg-border lg:w-44">
<span data-p className="absolute inset-0 origin-left rounded-full bg-primary" style={{ transform: 'scaleX(var(--p, 1))' }} />
</span>
<span className="sr-only">
{active + 1} {t.of} {n}
</span>
</div>
);
const rowClass = cn(
'relative flex gap-5 md:gap-6',
variant === 'timeline' && 'gap-10 md:gap-0',
variant === 'gallery' && 'gap-4 md:gap-8',
// padding lines the first item up with the container, the last one ends on the same margin
'px-6 lg:px-[max(2rem,calc((100%-var(--container-kit))/2))]',
phone === 'list' ? 'max-md:flex-col' : 'kit-no-scrollbar snap-x snap-mandatory overflow-x-auto scroll-px-6 max-md:pb-2 md:snap-none',
running ? 'w-full overflow-visible will-change-transform' : 'md:kit-no-scrollbar md:overflow-x-auto',
!running && fade,
);
const content = (
<div
ref={(el) => {
row.current = el;
fadeRef.current = el;
}}
role={running ? undefined : 'region'}
aria-label={running ? undefined : t.region}
tabIndex={running || phone === 'list' ? undefined : 0}
onFocus={onFocus}
className={cn(rowClass, 'focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-primary')}
>
{items.map((it, i) => (
<div key={i} data-item={i} className={cn('relative shrink-0 md:snap-start', running && 'will-change-transform', ITEM_W[variant], variant === 'gallery' && GALLERY_W[i % GALLERY_W.length])}>
{variant === 'cards' && <Card it={it} i={i} mark={running ? marks[i] : undefined} />}
{variant === 'timeline' && <Year it={it} i={i} n={n} mark={marks[i]} live={running} />}
{variant === 'gallery' && <Picture it={it} i={i} />}
{variant === 'chapters' && <Chapter it={it} i={i} t={t} />}
{variant === 'products' && <Product it={it} />}
</div>
))}
{variant === 'products' && action && (
<div data-item={n} className={cn('relative shrink-0 snap-start', ITEM_W.products)}>
<a href={action.href} className="group flex aspect-[4/5] flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-border-strong text-center transition-colors duration-500 hover:border-primary hover:bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-primary">
<span className="flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground transition-transform duration-500 group-hover:translate-x-1">
<Icon name="arrow-right" size={20} />
</span>
<span className="max-w-[10rem] text-balance text-[14.5px] font-medium text-foreground">{t.all}</span>
</a>
</div>
)}
</div>
);
return (
<section {...rest} className={cn('relative overflow-x-clip', !running && 'py-20 sm:py-24 lg:py-32', className)}>
<div ref={track} className="relative" style={running ? { height: `calc(100svh + ${dist}px)` } : undefined}>
<div className={cn(running && 'sticky top-0 flex h-svh flex-col justify-center gap-10 overflow-clip py-10 lg:gap-14')}>
<Header eyebrow={eyebrow} title={title} description={description} action={variant === 'products' ? undefined : action} aside={counter} />
<div className={cn('relative z-10', !running && 'mt-12 sm:mt-14')}>{content}</div>
{/* gallery: a large word behind the lower edge of the pictures, gliding at a third of their speed */}
{variant === 'gallery' && backdrop && (
<p
ref={back}
aria-hidden
className="pointer-events-none relative -mt-[0.8em] hidden select-none whitespace-nowrap pl-[max(2rem,calc((100%-var(--container-kit))/2))] text-[clamp(5rem,10vw,10rem)] font-semibold leading-none tracking-[-0.05em] text-[color-mix(in_oklab,var(--foreground)_9%,transparent)] md:block"
>
{backdrop}
</p>
)}
</div>
</div>
</section>
);
}Frequent questions
How do I install Sideways scroll?
With aiw add @ai-website/sideways-scroll. The command puts components/ui/sideways-scroll.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion and scroll-scene. It needs no further npm packages.
Which variants does Sideways scroll have?
5 variants, Service cards, Timeline, Gallery with a word behind, Wide chapters and Product row. You pick one with the variant prop, for example variant="timeline".
Can I translate the texts of Sideways scroll?
Yes. Sideways scroll carries 4 texts of its own in English and German, such as “Scrollable row”, “of” and “Learn more”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Sideways scroll?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sideways scroll with aiw_components and install it with aiw_add @ai-website/sideways-scroll. Name the variant in your prompt, for example variant="cards", and the assistant writes your content straight into the props.
Can I adjust Sideways scroll without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching SidewaysScroll code to copy.