Slice Gallery
Tall photo slices that share one width. The one you open widens into a full card while the others squeeze to make room; its title swings upright and the details slide up from under it.
Selected work
Five buildings, five quiet rooms
2021–2025
Private house · Comporta
A low house of lime and pine that opens to the dunes on three sides.
View projectHoliday house · Gotland
Tarred timber outside, pale ash inside, and one long window to the sea.
View projectimport { SliceGallery, SliceGalleryItem } from "@/components/ui/slice-gallery";
const projects = [
{
title: "Casa Alba",
year: "2025",
kind: "Private house",
place: "Comporta",
text: "A low house of lime and pine that opens to the dunes on three sides.",
photo: "1600585154340-be6161a56a0c",
accent: "#f2c48d",
},
{
title: "Hollow Pavilion",
year: "2024",
kind: "Gallery",
place: "Copenhagen",
text: "Folded white planes that pull daylight down into the rooms below.",
photo: "1487958449943-2429e8be8625",
accent: "#bcd7ff",
},
{
title: "Linden Studio",
year: "2023",
kind: "Showroom",
place: "Berlin",
text: "A furniture maker's floor, lit like the workshop next door.",
photo: "1524758631624-e2822e304c36",
accent: "#e9e1cf",
},
{
title: "Black Barn",
year: "2022",
kind: "Holiday house",
place: "Gotland",
text: "Tarred timber outside, pale ash inside, and one long window to the sea.",
photo: "1600585154526-990dced4db0d",
accent: "#ffb37a",
},
{
title: "Atelier Nine",
year: "2021",
kind: "Apartment",
place: "Milan",
text: "A ninth-floor flat reworked around a single oak table.",
photo: "1618219908412-a29a1bb7b86e",
accent: "#d8c3a0",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=1000&h=1000&q=80`;
const slug = (title: string) => title.toLowerCase().replace(/\s+/g, "-");
export function SliceGalleryDemo() {
return (
<section className="w-full max-w-5xl">
<div className="mb-6 flex items-end justify-between gap-4">
<div>
<p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Selected work</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Five buildings, five quiet rooms
</h3>
</div>
<p className="hidden font-mono text-xs text-zinc-500 sm:block">2021–2025</p>
</div>
<SliceGallery className="h-[34rem] sm:h-[30rem]">
{projects.map((project) => (
<SliceGalleryItem
key={project.title}
title={project.title}
label={project.year}
image={photo(project.photo)}
accent={project.accent}
>
<p className="text-sm text-white/70">
{project.kind} · {project.place}
</p>
<p className="mt-1 max-w-xs text-[15px] leading-snug text-pretty text-white/90">
{project.text}
</p>
<a
href={`#${slug(project.title)}`}
className="mt-3 inline-flex items-center gap-1.5 rounded text-sm font-medium text-white underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-white/70"
>
View project <span aria-hidden>→</span>
</a>
</SliceGalleryItem>
))}
</SliceGallery>
</section>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/slice-gallery.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/slice-galleryInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/slice-gallery.tsx"use client"; import * as React from "react"; import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type MotionStyle, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * SLICE GALLERY STORYBOARD * * rest tall slices share the width. One is open; the rest * are thin windows onto their photos, shaded, with * their titles running up the left edge * hover a slice brightens as soon as the pointer is on it, * and opens when the pointer slows or settles, so a * quick sweep across the row doesn't set it rippling * open it widens on a spring while the others squeeze to * make room. Photos never stretch: each is cut to the * open size, so the window just widens, and the photo * eases back from a slight zoom. The title swings * upright on its first letter, then rises as the * details slide up from under it, and the strip on * top warms to the slice's accent * close the details go first, then the title swings back * down as the slice narrows * touch tap a slice, or drag a finger across the row to * scrub through them * keys ←/→ (↑/↓ when stacked), Home and End * play with autoPlay, the open slice's strip fills like a * story timer, then the next one opens. It pauses * while the pointer or keyboard focus is inside, or * the gallery is off screen * narrow under 36rem the slices stack and open downward * reduced no springs, zoom or autoplay: slices switch at once * ───────────────────────────────────────────────────────── */ const SLICE = { open: { type: "spring", visualDuration: 0.5, bounce: 0 }, dwell: 90, // ms the pointer rests on a slice before it opens slow: 0.35, // px/ms; a pointer moving slower than this opens a slice at once zoom: 0.08, // closed photos sit this much closer in shade: 0.4, // darkness over closed slices pad: 20, // px around a slice's text when the slices sit side by side (p-5) headroom: 104, // px a sideways title leaves at the top for the strip and label stack: 36, // rem; narrower galleries stack their slices (Tailwind's @xl) scrub: 10, // px a finger travels before a tap counts as a scrub } as const; export type SliceGalleryOrientation = "auto" | "horizontal" | "vertical"; // Literal class names so Tailwind generates them. "auto" switches at the gallery's own @xl width. const DIRECTION: Record<SliceGalleryOrientation, string> = { auto: "flex-col @xl:flex-row", horizontal: "flex-row", vertical: "flex-col", }; // Side by side, a closed title lies along the slice's left edge, turning on its first letter. const TURN: Record<SliceGalleryOrientation, string> = { auto: "@xl:rotate-[calc(var(--slice-turn)*-90deg)]", horizontal: "rotate-[calc(var(--slice-turn)*-90deg)]", vertical: "", }; const PAD: Record<SliceGalleryOrientation, string> = { auto: "p-4 @xl:p-5", horizontal: "p-5", vertical: "p-4", }; // The label sits in the top corner side by side, and at the end of the title row when stacked. const LABEL_TOP: Record<SliceGalleryOrientation, string> = { auto: "hidden @xl:block", horizontal: "block", vertical: "hidden", }; const LABEL_ROW: Record<SliceGalleryOrientation, string> = { auto: "@xl:hidden", horizontal: "hidden", vertical: "", }; type Layout = { horizontal: boolean; /** Length of the open slice along the row, in px. Null until measured. */ open: number | null; /** Depth of the row across the slices (its height side by side), in px. */ cross: number | null; }; type Gallery = { active: number; grow: number; orientation: SliceGalleryOrientation; layout: Layout; reduced: boolean; playing: boolean; progress: MotionValue<number>; select: (index: number) => void; onKeyDown: (event: React.KeyboardEvent, from: number) => void; }; const GalleryContext = React.createContext<Gallery | null>(null); const IndexContext = React.createContext(-1); function useGallery() { const gallery = React.useContext(GalleryContext); if (!gallery) throw new Error("<SliceGalleryItem> must be rendered inside <SliceGallery>."); return gallery; } /* ── SliceGallery ───────────────────────────────────────── */ export interface SliceGalleryProps extends React.HTMLAttributes<HTMLDivElement> { /** The open slice. Leave unset to let the gallery manage it. */ index?: number; /** The slice that starts open. */ defaultIndex?: number; /** Called when a slice opens: hover, tap, scrub, keys or autoplay. */ onIndexChange?: (index: number) => void; /** How many times longer the open slice is than a closed one. */ grow?: number; /** Space between slices, in px. */ gap?: number; /** "hover" opens slices under the pointer (and under a dragging finger); "click" waits for a click or tap. */ openOn?: "hover" | "click"; /** Opens the next slice every this many ms, with the strip as its timer. Off for reduced motion. */ autoPlay?: number; /** "auto" sits the slices side by side, and stacks them when the gallery is narrower than 36rem. */ orientation?: SliceGalleryOrientation; } /** * Tall slices that share a fixed width. The open one widens into a full card * and the rest squeeze thinner to make room. */ export function SliceGallery({ index, defaultIndex = 0, onIndexChange, grow = 5, gap = 8, openOn = "hover", autoPlay, orientation = "auto", className, style, children, ...props }: SliceGalleryProps) { const items = React.Children.toArray(children).filter(React.isValidElement); const count = items.length; const [own, setOwn] = React.useState(defaultIndex); const active = count ? Math.min(Math.max(index ?? own, 0), count - 1) : -1; const reduced = useReducedMotion() ?? false; const playing = !!autoPlay && autoPlay > 0 && !reduced && count > 1; const listRef = React.useRef<HTMLDivElement>(null); const activeRef = React.useRef(active); const latest = React.useRef({ index, onIndexChange, count }); React.useLayoutEffect(() => { activeRef.current = active; latest.current = { index, onIndexChange, count }; }); const select = React.useCallback((next: number) => { const { index, onIndexChange, count } = latest.current; if (next === activeRef.current || next < 0 || next >= count) return; activeRef.current = next; if (index === undefined) setOwn(next); onIndexChange?.(next); }, []); /* Measure: which way the slices run, and how long the open one is. */ const [layout, setLayout] = React.useState<Layout>(() => ({ horizontal: orientation !== "vertical", open: null, cross: null, })); React.useLayoutEffect(() => { const list = listRef.current; if (!list) return; const observer = new ResizeObserver(() => { const width = list.offsetWidth; const height = list.offsetHeight; const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const horizontal = orientation === "horizontal" || (orientation === "auto" && width >= SLICE.stack * rem); const others = Math.max(0, count - 1); const main = horizontal ? width : height; const open = Math.round(((main - gap * others) * grow) / (grow + others)); const cross = horizontal ? height : width; setLayout((prev) => prev.horizontal === horizontal && prev.open === open && prev.cross === cross ? prev : { horizontal, open, cross }, ); }); observer.observe(list); return () => observer.disconnect(); }, [orientation, gap, grow, count]); /* Autoplay: the progress runs while nothing holds it, then opens the next slice. */ const progress = useMotionValue(0); const [hovered, setHovered] = React.useState(false); const [focused, setFocused] = React.useState(false); const [seen, setSeen] = React.useState(true); const paused = hovered || focused || !seen; React.useLayoutEffect(() => { progress.jump(0); }, [active, progress]); React.useEffect(() => { if (!playing || paused) return; const controls = animate(progress, 1, { duration: ((autoPlay ?? 0) / 1000) * (1 - progress.get()), ease: "linear", onComplete: () => select((activeRef.current + 1) % latest.current.count), }); return () => controls.stop(); }, [playing, paused, active, autoPlay, progress, select]); React.useEffect(() => { const list = listRef.current; if (!playing || !list) return; let inView = true; const update = () => setSeen(inView && document.visibilityState === "visible"); const observer = new IntersectionObserver( ([entry]) => { inView = entry.isIntersecting; update(); }, { threshold: 0.2 }, ); observer.observe(list); document.addEventListener("visibilitychange", update); return () => { observer.disconnect(); document.removeEventListener("visibilitychange", update); }; }, [playing]); /* Pointer: hover intent for mice, scrubbing for fingers. */ const pointer = React.useRef({ x: NaN, y: NaN, t: 0, speed: Infinity, timer: 0, scrub: false, startX: 0, swallowClick: false, }); React.useEffect(() => () => window.clearTimeout(pointer.current.timer), []); const sliceAt = (x: number, y: number) => { const hit = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-slice-index]"); return hit && listRef.current?.contains(hit) ? Number(hit.dataset.sliceIndex) : -1; }; const onPointerMove = (event: React.PointerEvent) => { if (openOn !== "hover") return; const p = pointer.current; if (event.pointerType !== "mouse") { if (p.scrub) select(sliceAt(event.clientX, event.clientY)); return; } const dx = event.clientX - p.x; const dy = event.clientY - p.y; if (dx === 0 && dy === 0) return; // the slices moved under a still pointer const dt = event.timeStamp - p.t; const speed = Number.isNaN(dx) ? Infinity : dt > 0 ? Math.hypot(dx, dy) / dt : p.speed; p.speed = Number.isFinite(p.speed) && Number.isFinite(speed) ? (p.speed + speed) / 2 : speed; p.x = event.clientX; p.y = event.clientY; p.t = event.timeStamp; window.clearTimeout(p.timer); const next = sliceAt(p.x, p.y); if (next < 0 || next === activeRef.current) return; if (p.speed < SLICE.slow) select(next); else p.timer = window.setTimeout(() => select(next), SLICE.dwell); }; const onPointerDown = (event: React.PointerEvent) => { const p = pointer.current; p.swallowClick = false; if (event.pointerType === "mouse" || !layout.horizontal || openOn !== "hover") return; p.scrub = true; p.startX = event.clientX; }; const endScrub = (event: React.PointerEvent) => { const p = pointer.current; if (!p.scrub) return; p.scrub = false; // A finger that slid across the row shouldn't also tap the slice it started on. p.swallowClick = Math.abs(event.clientX - p.startX) > SLICE.scrub; }; const onKeyDown = (event: React.KeyboardEvent, from: number) => { const back = layout.horizontal ? "ArrowLeft" : "ArrowUp"; const forward = layout.horizontal ? "ArrowRight" : "ArrowDown"; let to = -1; if (event.key === forward) to = (from + 1) % count; else if (event.key === back) to = (from - 1 + count) % count; else if (event.key === "Home") to = 0; else if (event.key === "End") to = count - 1; if (to < 0) return; event.preventDefault(); select(to); listRef.current ?.querySelector<HTMLElement>(`[data-slice-index="${to}"] [data-slice-trigger]`) ?.focus(); }; const titleMax = layout.horizontal && layout.open && layout.cross ? `${Math.max(0, Math.min(layout.open - 2 * SLICE.pad, layout.cross - SLICE.headroom))}px` : undefined; return ( <GalleryContext.Provider value={{ active, grow, orientation, layout, reduced, playing, progress, select, onKeyDown }} > <div {...props} className={cn("@container relative h-[30rem] w-full", className)} style={{ ...style, "--slice-title-max": titleMax } as React.CSSProperties} > <div ref={listRef} className={cn("flex size-full", DIRECTION[orientation])} style={{ gap, touchAction: layout.horizontal && openOn === "hover" ? "pan-y" : undefined }} onPointerMove={onPointerMove} onPointerDown={onPointerDown} onPointerUp={endScrub} onPointerCancel={endScrub} onPointerEnter={(event) => event.pointerType === "mouse" && setHovered(true)} onPointerLeave={() => { const p = pointer.current; window.clearTimeout(p.timer); p.x = NaN; p.speed = Infinity; setHovered(false); }} onClickCapture={(event) => { if (!pointer.current.swallowClick) return; pointer.current.swallowClick = false; event.preventDefault(); event.stopPropagation(); }} onFocus={(event) => { if (event.target.matches(":focus-visible")) setFocused(true); }} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false); }} > {items.map((item, i) => ( <IndexContext.Provider key={item.key ?? i} value={i}> {item} </IndexContext.Provider> ))} </div> </div> </GalleryContext.Provider> ); } /* ── SliceGalleryItem ───────────────────────────────────── */ export interface SliceGalleryItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> { /** One short line. Runs up the edge of a closed slice and turns upright when it opens. */ title: React.ReactNode; /** Photo URL. It's cut to the open slice's size, so closed slices show a strip of its middle. */ image: string; /** Alt text for the photo. Leave empty when the title says it all. */ alt?: string; /** Small text in the top corner, like a year or a number. Moves to the end of the title row when stacked. */ label?: React.ReactNode; /** Color the strip on top warms to when the slice opens. */ accent?: string; /** Classes for the photo. The slice is a `group/slice` with `data-state="open"` or `"closed"`. */ imageClassName?: string; /** Details that slide up under the title when the slice opens. */ children?: React.ReactNode; } /** One slice. Render inside `SliceGallery`. */ export function SliceGalleryItem({ title, image, alt = "", label, accent = "#ffffff", imageClassName, className, children, ...props }: SliceGalleryItemProps) { const { active, grow, orientation, layout, reduced, playing, progress, select, onKeyDown } = useGallery(); const index = React.useContext(IndexContext); const isOpen = active === index; const id = React.useId(); const titleId = `${id}-title`; const detailsId = `${id}-details`; const open = useMotionValue(isOpen ? 1 : 0); React.useEffect(() => { const target = isOpen ? 1 : 0; if (reduced) { open.jump(target); return; } const controls = animate(open, target, SLICE.open); return () => controls.stop(); }, [isOpen, reduced, open]); const flexGrow = useTransform(open, [0, 1], [1, grow]); const turn = useTransform(open, [0.15, 0.85], [1, 0]); const reveal = useTransform(open, [0.4, 1], [0, 1]); const shade = useTransform(open, [0, 1], [SLICE.shade, 0]); const zoom = useTransform(open, [0, 1], [1 + SLICE.zoom, 1]); const glow = useTransform(open, [0.2, 0.8], [0, 1]); // The strip fills with the autoplay timer while open, and holds its fill as it fades out. const fill = useMotionValue(playing && isOpen ? 0 : 1); useMotionValueEvent(progress, "change", (value) => { if (playing && isOpen) fill.set(value); }); React.useEffect(() => { if (!playing) fill.set(1); else if (isOpen) fill.set(progress.get()); }, [playing, isOpen, fill, progress]); const length = layout.open ?? undefined; const horizontal = layout.horizontal; return ( <motion.div data-slice-index={index} className="min-h-0 min-w-0 basis-0" style={{ flexGrow }} > <div {...props} data-state={isOpen ? "open" : "closed"} className={cn( "group/slice relative isolate size-full overflow-hidden rounded-2xl bg-zinc-800 outline-zinc-900 select-none dark:outline-zinc-100", "has-[[data-slice-trigger]:focus-visible]:outline-2 has-[[data-slice-trigger]:focus-visible]:outline-offset-2", className, )} > <motion.img src={image} alt={alt} draggable={false} decoding="async" className={cn( "pointer-events-none absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 object-cover", imageClassName, )} style={{ width: horizontal && length ? length : "100%", height: !horizontal && length ? length : "100%", scale: reduced ? 1 : zoom, }} /> <div aria-hidden className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0/0.78),rgb(0_0_0/0.42)_28%,rgb(0_0_0/0)_62%,rgb(0_0_0/0.22))]" /> <motion.div aria-hidden className="pointer-events-none absolute inset-0" style={{ opacity: shade }}> <div className="size-full bg-black transition-opacity duration-300 group-hover/slice:opacity-50" /> </motion.div> <button type="button" data-slice-trigger tabIndex={isOpen ? 0 : -1} aria-expanded={isOpen} aria-controls={detailsId} aria-labelledby={titleId} onClick={() => select(index)} onKeyDown={(event) => onKeyDown(event, index)} className={cn("absolute inset-0 z-10 outline-none", isOpen ? "cursor-default" : "cursor-pointer")} /> <div aria-hidden className="pointer-events-none absolute inset-x-3 top-3 z-20 h-[3px] overflow-hidden rounded-full bg-white/25" > <motion.div className="h-full origin-left rounded-full" style={{ scaleX: fill, opacity: glow, backgroundColor: accent }} /> </div> {label != null && ( <span className={cn( "pointer-events-none absolute top-7 left-5 z-20 max-w-[calc(100%-2.5rem)] truncate font-mono text-xs tracking-wide text-white/75", LABEL_TOP[orientation], )} > {label} </span> )} <div className={cn( "pointer-events-none absolute inset-x-0 bottom-0 z-20 text-white [text-shadow:0_1px_14px_rgb(0_0_0/0.3)]", PAD[orientation], )} > <motion.div className="translate-y-[calc((100%-2rem)*(1-var(--slice-reveal)))]" style={ { "--slice-reveal": reveal, width: horizontal && length ? length - 2 * SLICE.pad : undefined, } as MotionStyle } > <div className="flex h-8 items-center gap-3"> <motion.span id={titleId} className={cn( "max-w-[var(--slice-title-max,100%)] min-w-0 origin-[1rem_1rem] truncate text-xl leading-8 font-medium tracking-tight", TURN[orientation], )} style={{ "--slice-turn": turn } as MotionStyle} > {title} </motion.span> {label != null && ( <span className={cn( "ml-auto shrink-0 font-mono text-xs tracking-wide text-white/75", LABEL_ROW[orientation], )} > {label} </span> )} </div> <motion.div id={detailsId} inert={!isOpen} className="pointer-events-auto pt-2 select-text" style={{ opacity: reveal }} > {children} </motion.div> </motion.div> </div> </div> </motion.div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Slice Gallery. Each one installs whole, with the component included.
Team Portraits
Six portraits in black and white, with the open one in color through `imageClassName`. The role takes the person's accent. On a phone the portraits stack; on a touch screen side by side, drag a finger across them to flip through faces.
The studio
Six people, four time zones
Creative Director
Sets the tone for every launch, then argues about kerning.
ines.moreau@studio.exampleimport { SliceGallery, SliceGalleryItem } from "@/components/ui/slice-gallery";
const people = [
{
name: "Ines Moreau",
role: "Creative Director",
city: "Paris",
note: "Sets the tone for every launch, then argues about kerning.",
photo: "1438761681033-6461ffad8d80",
accent: "#f5b8a8",
},
{
name: "Rafael Santos",
role: "Design Lead",
city: "Lisbon",
note: "Runs the design system and the Friday crits.",
photo: "1507003211169-0a1dd7228f2d",
accent: "#a8d5f5",
},
{
name: "Mei Tanaka",
role: "Motion Designer",
city: "Tokyo",
note: "Makes the product move, and knows when it shouldn't.",
photo: "1534528741775-53994a69daeb",
accent: "#c8b6ff",
},
{
name: "Luca Bianchi",
role: "Engineering Lead",
city: "Milan",
note: "Ships the hard parts and keeps the build under a minute.",
photo: "1500648767791-00dcc994a43e",
accent: "#b4e3c3",
},
{
name: "Amara Okafor",
role: "Strategy Director",
city: "Lagos",
note: "Turns research into the brief everyone else works from.",
photo: "1531123897727-8f129e1688ce",
accent: "#ffd28a",
},
{
name: "Jonas Weber",
role: "Brand Designer",
city: "Berlin",
note: "Draws the logos, then draws them again, smaller.",
photo: "1539571696357-5a69c17a67c6",
accent: "#f7a8cf",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&crop=faces&w=800&h=1000&q=80`;
const slug = (name: string) => name.toLowerCase().replace(/\s+/g, ".");
export function SliceGalleryTeamDemo() {
return (
<section className="w-full max-w-4xl">
<div className="mb-6">
<p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">The studio</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Six people, four time zones
</h3>
</div>
<SliceGallery grow={4} gap={6} defaultIndex={2} className="h-[36rem] sm:h-[28rem]">
{people.map((person) => (
<SliceGalleryItem
key={person.name}
title={person.name}
label={person.city}
image={photo(person.photo)}
accent={person.accent}
className="rounded-xl"
imageClassName="grayscale transition-[filter] duration-700 group-data-[state=open]/slice:grayscale-0"
>
<p className="text-sm font-medium" style={{ color: person.accent }}>
{person.role}
</p>
<p className="mt-1 max-w-[16rem] text-sm leading-snug text-pretty text-white/85">
{person.note}
</p>
<a
href={`mailto:${slug(person.name)}@studio.example`}
className="mt-3 inline-flex rounded text-xs text-white/70 underline-offset-4 outline-none hover:text-white hover:underline focus-visible:ring-2 focus-visible:ring-white/70"
>
{slug(person.name)}@studio.example
</a>
</SliceGalleryItem>
))}
</SliceGallery>
</section>
);
}
Hero
A travel hero that plays on its own: the open slice's strip fills like a story timer, then the next place opens. Hovering pauses it. The counter above follows along through `index` and `onIndexChange`.
Small stays, slow trips
Go somewhere quiet.
Five places we keep going back to, each with one house we'd book again.
01 / 05Kyoto
Plan a tripA machiya on a lane behind Yasaka, breakfast in the garden.
6 nights from€1,480
A lake cabin with canoes at the dock and no signal at all.
5 nights from€1,320
Black sand, a grey sea and a turf-roofed guesthouse above it.
4 nights from€1,150
Tiled rooms in Alfama, a short tram ride from the river.
4 nights from€890
Redwoods above the cove and the Pacific under the deck.
5 nights from€1,690
"use client";
import * as React from "react";
import { SliceGallery, SliceGalleryItem } from "@/components/ui/slice-gallery";
const stays = [
{
place: "Kyoto",
country: "Japan",
nights: 6,
price: "€1,480",
note: "A machiya on a lane behind Yasaka, breakfast in the garden.",
photo: "1493976040374-85c8e12f0c0e",
accent: "#f6b38e",
},
{
place: "Banff",
country: "Canada",
nights: 5,
price: "€1,320",
note: "A lake cabin with canoes at the dock and no signal at all.",
photo: "1493246507139-91e8fad9978e",
accent: "#8fd3e6",
},
{
place: "Vík",
country: "Iceland",
nights: 4,
price: "€1,150",
note: "Black sand, a grey sea and a turf-roofed guesthouse above it.",
photo: "1500043357865-c6b8827edf10",
accent: "#d3dbe2",
},
{
place: "Lisbon",
country: "Portugal",
nights: 4,
price: "€890",
note: "Tiled rooms in Alfama, a short tram ride from the river.",
photo: "1536663815808-535e2280d2c2",
accent: "#ffcf8a",
},
{
place: "Big Sur",
country: "USA",
nights: 5,
price: "€1,690",
note: "Redwoods above the cove and the Pacific under the deck.",
photo: "1510414842594-a61c69b5ae57",
accent: "#8fe0c9",
},
];
const photo = (id: string) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=1100&h=1000&q=80`;
const pad = (n: number) => String(n).padStart(2, "0");
export function SliceGalleryHeroDemo() {
const [index, setIndex] = React.useState(0);
const stay = stays[index];
return (
<section className="w-full max-w-5xl">
<div className="mb-8 grid gap-6 sm:grid-cols-[1fr_auto] sm:items-end">
<div>
<p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">
Small stays, slow trips
</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight text-balance text-zinc-900 sm:text-5xl dark:text-zinc-50">
Go somewhere quiet.
</h2>
<p className="mt-3 max-w-md text-pretty text-zinc-600 dark:text-zinc-400">
Five places we keep going back to, each with one house we'd book again.
</p>
</div>
<div className="flex items-center gap-5">
<p aria-live="polite" className="font-mono text-xs text-zinc-500 tabular-nums">
{pad(index + 1)} / {pad(stays.length)}
<span className="ml-2 text-zinc-900 dark:text-zinc-100">{stay.place}</span>
</p>
<a
href="#plan"
className="rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition-colors outline-none hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 dark:focus-visible:ring-offset-zinc-950"
>
Plan a trip
</a>
</div>
</div>
<SliceGallery
index={index}
onIndexChange={setIndex}
autoPlay={5000}
className="h-[34rem] sm:h-[30rem]"
>
{stays.map((item) => (
<SliceGalleryItem
key={item.place}
title={item.place}
label={item.country}
image={photo(item.photo)}
accent={item.accent}
>
<p className="max-w-xs text-[15px] leading-snug text-pretty text-white/90">{item.note}</p>
<p className="mt-3 flex items-baseline gap-2 text-sm text-white/70">
<span>{item.nights} nights from</span>
<span className="text-lg font-medium text-white">{item.price}</span>
</p>
</SliceGalleryItem>
))}
</SliceGallery>
</section>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | React.ReactNode | None | The slices, as SliceGalleryItems. |
defaultIndex | number | 0 | The slice that starts open. |
index | number | None | The open slice. Set it with onIndexChange to control the gallery from outside. |
onIndexChange | (index: number) => void | None | Called when a slice opens, by hover, tap, scrub, keys or autoplay. |
grow | number | 5 | How many times longer the open slice is than a closed one. |
gap | number | 8 | Space between slices, in px. |
openOn | "hover" | "click" | "hover" | hover opens the slice under the pointer once it slows down, and the slice under a finger dragged across the row. click waits for a click or tap. |
autoPlay | number | None | Opens the next slice every this many ms, with the open slice's strip filling as its timer. Pauses while the pointer or keyboard focus is inside or the gallery is off screen. Off for reduced motion. |
orientation | "auto" | "horizontal" | "vertical" | "auto" | auto puts the slices side by side and stacks them when the gallery is narrower than 36rem. |
className | string | None | Classes for the gallery. Set its height here; the default is h-[30rem]. |
item.titleRequired | React.ReactNode | None | SliceGalleryItem: one short line. It runs up the edge of a closed slice and swings upright when the slice opens. |
item.imageRequired | string | None | SliceGalleryItem: photo URL. It's cut to the open slice's size, so a closed slice shows a strip from its middle and opening it just widens the window. |
item.alt | string | "" | SliceGalleryItem: alt text for the photo. Leave it empty when the title says it all. |
item.label | React.ReactNode | None | SliceGalleryItem: small text in the top corner, like a year or a city. Moves to the end of the title row when stacked. |
item.accent | string | "#ffffff" | SliceGalleryItem: the color the strip on top warms to when the slice opens. |
item.children | React.ReactNode | None | SliceGalleryItem: details that slide up under the title when the slice opens. |
item.imageClassName | string | None | SliceGalleryItem: classes for the photo. The slice is a group/slice with data-state="open" or "closed", so grayscale group-data-[state=open]/slice:grayscale-0 colors only the open one. |
item.className | string | None | SliceGalleryItem: classes for the slice, like its corner radius or background. |
childrenRequiredType
React.ReactNodeThe slices, as
SliceGalleryItems.defaultIndexType
numberDefault0The slice that starts open.
indexType
numberThe open slice. Set it with
onIndexChangeto control the gallery from outside.onIndexChangeType
(index: number) => voidCalled when a slice opens, by hover, tap, scrub, keys or autoplay.
growType
numberDefault5How many times longer the open slice is than a closed one.
gapType
numberDefault8Space between slices, in px.
openOnType
"hover" | "click"Default"hover"hoveropens the slice under the pointer once it slows down, and the slice under a finger dragged across the row.clickwaits for a click or tap.autoPlayType
numberOpens the next slice every this many ms, with the open slice's strip filling as its timer. Pauses while the pointer or keyboard focus is inside or the gallery is off screen. Off for reduced motion.
orientationType
"auto" | "horizontal" | "vertical"Default"auto"autoputs the slices side by side and stacks them when the gallery is narrower than 36rem.classNameType
stringClasses for the gallery. Set its height here; the default is
h-[30rem].item.titleRequiredType
React.ReactNodeSliceGalleryItem: one short line. It runs up the edge of a closed slice and swings upright when the slice opens.item.imageRequiredType
stringSliceGalleryItem: photo URL. It's cut to the open slice's size, so a closed slice shows a strip from its middle and opening it just widens the window.item.altType
stringDefault""SliceGalleryItem: alt text for the photo. Leave it empty when the title says it all.item.labelType
React.ReactNodeSliceGalleryItem: small text in the top corner, like a year or a city. Moves to the end of the title row when stacked.item.accentType
stringDefault"#ffffff"SliceGalleryItem: the color the strip on top warms to when the slice opens.item.childrenType
React.ReactNodeSliceGalleryItem: details that slide up under the title when the slice opens.item.imageClassNameType
stringSliceGalleryItem: classes for the photo. The slice is agroup/slicewithdata-state="open"or"closed", sograyscale group-data-[state=open]/slice:grayscale-0colors only the open one.item.classNameType
stringSliceGalleryItem: classes for the slice, like its corner radius or background.