Expanding Panels
Installation
npx shadcn@latest add https://uibrik.com/r/expanding-panels.jsonInstall the packages
npm install motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/expanding-panels/expanding-panels.tsx"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { animate, motion, motionValue, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue, } from "motion/react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // Expanding panels. A row of image panels where one is open and the rest are // narrow. Choosing another opens it and closes the one before, and only those // two move: one grows exactly as fast as the other shrinks, so the row never // changes length and the panels in between stay put, however far apart the two // are. // // Every panel is laid out at its open size and revealed with a clip-path, so an // image is never squashed or re-cropped. Each panel has its own openness, all // moved by the same spring, which sets its size, its place in the row and the // drift and zoom of its image behind the clip. Narrow stages stack the panels // top to bottom instead. export type ExpandingPanel = { src: string; alt: string; }; export type ExpandingPanelsProps = { panels?: ExpandingPanel[]; /** Panel open to begin with, from 0. */ defaultActive?: number; /** Space between panels, px. */ gap?: number; /** Width of a closed panel, px. */ collapsed?: number; /** Height of the row, px. */ height?: number; /** Corner radius, px. */ rounding?: number; /** Seconds a change takes. */ duration?: number; /** How far images drift behind their panel, %. 0 turns it off. */ parallax?: number; /** Open the next panel on its own, round and round. Holds while pointed at * or focused, and with reduced motion. */ autoplay?: boolean; /** How long each panel stays open while playing, ms. */ interval?: number; /** Label for screen readers. */ label?: string; /** Called with the newly open panel, from 0. */ onChange?: (index: number) => void; className?: string; }; const PANELS: ExpandingPanel[] = [ { src: "https://uibrik.com/images/1.jpg", alt: "Morning light" }, { src: "https://uibrik.com/images/2.jpg", alt: "Sunlit clearing" }, { src: "https://uibrik.com/images/3.jpg", alt: "Wildflowers" }, { src: "https://uibrik.com/images/4.jpg", alt: "Quiet shore" }, { src: "https://uibrik.com/images/5.jpg", alt: "River and pines" }, ]; const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); // Motion writes numbers into server HTML to six places, so values are rounded // to fewer than that and the client's first render matches what the server sent. const round = (n: number) => Math.round(n * 1e2) / 1e2; // Below this stage width the panels stack top to bottom. const STACK_BELOW = 560; // Images are drawn a little larger than their panel so drifting never shows an edge. const OVERSCAN = 1.22; type PanelProps = { panel: ExpandingPanel; index: number; active: boolean; /** How open every panel is, 0 to 1. They always add up to 1. */ opens: MotionValue<number>[]; pointer: MotionValue<number>; vertical: boolean; /** Length of a closed and an open panel along the row. */ narrow: number; wide: number; gap: number; rounding: number; parallax: number; onSelect: (index: number) => void; buttonRef: (el: HTMLButtonElement | null) => void; }; function Panel({ panel, index, active, opens, pointer, vertical, narrow, wide, gap, rounding, parallax, onSelect, buttonRef, }: PanelProps) { // 1 while open, 0 while closed. The panel being left and the one being opened // run the same spring from opposite ends, so their two lengths always add up // to the same total and nothing else in the row has a reason to move. const open = opens[index]; const length = useTransform(open, (t) => narrow + (wide - narrow) * t); // Where this panel starts: everything before it, at its current size. const start = useTransform(opens, (all: number[]) => { let at = 0; for (let j = 0; j < index; j++) at += narrow + (wide - narrow) * clamp(all[j], 0, 1) + gap; return round(at); }); // A beat behind `open`: the gap between the two is the parallax. const behind = useSpring(open, { stiffness: 46, damping: 15, mass: 1 }); const clipPath = useTransform(length, (l) => { const hidden = round(wide - l); return vertical ? `inset(0px 0px ${hidden}px 0px round ${rounding}px)` : `inset(0px ${hidden}px 0px 0px round ${rounding}px)`; }); // The picture sits behind the clip on its own, slower layer: // - kept centred it would stand still as its panel changes size, so it is // drifted less than that and the clip slides over it, // - a closed picture is zoomed in a little and settles as its panel opens, // so it seems to come forward, // - while its panel opens or closes the picture trails it, then catches up, // - the pointer nudges it a little more. // Everything but the pointer comes from this panel's own openness, so a panel // that is not changing is not touched. The sum is held inside what the zoom // leaves spare, so no edge ever shows. const zoom = (t: number) => OVERSCAN + (1 - t) * 0.1 * parallax; const scale = useTransform(open, (t) => round(zoom(t))); const drift = useTransform([length, open, behind, pointer], ([l, t, lag, ptr]: number[]) => { const spare = ((zoom(t) - 1) / 2) * wide; const nudge = (-(t - lag) * 0.3 + ptr * 0.02) * wide * parallax; return round(-(wide - l) * (0.5 - 0.3 * parallax) + clamp(nudge, -spare, spare)); }); // Closed panels sit back a little so the open one reads first. const veil = useTransform(open, (t) => round((1 - t) * 0.28)); const along = vertical ? "y" : "x"; const size = vertical ? { width: "100%", height: wide } : { width: wide, height: "100%" }; return ( <motion.div style={{ ...size, [along]: start, clipPath }} className="absolute top-0 left-0"> <button ref={buttonRef} type="button" aria-expanded={active} aria-label={panel.alt} onClick={() => onSelect(index)} className="relative block size-full cursor-pointer overflow-hidden bg-muted outline-none select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground/40" > <motion.div style={{ [along]: drift, scale }} className="absolute inset-0 will-change-transform" > {/* eslint-disable-next-line @next/next/no-img-element */} <img src={panel.src} alt="" draggable={false} className="size-full object-cover" /> </motion.div> <motion.span aria-hidden style={{ opacity: veil }} className="pointer-events-none absolute inset-0 bg-black" /> </button> </motion.div> ); } export function ExpandingPanels({ panels = PANELS, defaultActive = 0, gap = 5, collapsed = 64, height = 420, rounding = 12, duration = 0.85, parallax = 35, autoplay = true, interval = 3000, label = "Gallery", onChange, className, }: ExpandingPanelsProps) { const reduce = useReducedMotion(); const [stageRef, { width: stageWidth }] = useMeasure(); const total = panels.length; const [initial] = useState(() => clamp(Math.round(defaultActive), 0, Math.max(0, total - 1))); const [active, setActive] = useState(initial); // One openness per panel, all moved by the same spring. const opens = useMemo( () => Array.from({ length: total }, (_, i) => motionValue(i === initial ? 1 : 0)), [total, initial], ); const rawPointer = useMotionValue(0); const pointer = useSpring(rawPointer, { stiffness: 120, damping: 24, mass: 0.6 }); const buttons = useRef<(HTMLButtonElement | null)[]>([]); const [held, setHeld] = useState(false); const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }); useEffect(() => { if (reduce) { opens.forEach((o, i) => o.set(i === active ? 1 : 0)); return; } // A spring that never overshoots: it leaves at once and has a long, soft // tail, and a click part-way through carries on from the speed it had. // The same spring on every panel keeps the one closing and the one opening // exactly in step, and the rest stay where they are. const controls = opens.map((o, i) => animate(o, i === active ? 1 : 0, { type: "spring", duration, bounce: 0 })); return () => controls.forEach((c) => c.stop()); }, [active, duration, reduce, opens]); // Next panel after a rest. The clock restarts on every change, a click // included, so a panel someone chose is never taken away at once. useEffect(() => { if (!autoplay || held || reduce || total < 2) return; const wait = Math.max(interval, duration * 1000 + 600); const timer = window.setTimeout(() => { const next = (active + 1) % total; setActive(next); onChangeRef.current?.(next); }, wait); return () => window.clearTimeout(timer); }, [autoplay, held, reduce, total, interval, duration, active]); const select = (index: number) => { if (index === active) return; setActive(index); onChange?.(index); }; const vertical = stageWidth > 0 && stageWidth < STACK_BELOW; const gaps = Math.max(0, total - 1) * gap; // Along the row: a closed panel and the open one, which takes what is left. const narrow = vertical ? 64 : Math.round(clamp(collapsed, 48, Math.max(48, (stageWidth - gaps) / Math.max(2, total + 1)))); const wide = vertical ? 240 : Math.max(narrow, Math.round(stageWidth - gaps - narrow * Math.max(0, total - 1))); const stageHeight = vertical ? (total - 1) * (narrow + gap) + wide : height; const strength = reduce ? 0 : clamp(parallax, 0, 100) / 100; const onKeyDown = (e: React.KeyboardEvent) => { const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; if (!step) return; e.preventDefault(); const next = clamp(active + step, 0, total - 1); select(next); buttons.current[next]?.focus(); }; if (total === 0) return null; return ( <div role="group" aria-label={label} className={cn("w-full max-w-5xl", className)}> <div ref={stageRef} onKeyDown={onKeyDown} onPointerEnter={() => setHeld(true)} onFocus={() => setHeld(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHeld(false); }} onPointerMove={(e) => { const r = e.currentTarget.getBoundingClientRect(); const at = vertical ? (e.clientY - r.top) / r.height : (e.clientX - r.left) / r.width; rawPointer.set(clamp(at * 2 - 1, -1, 1)); }} onPointerLeave={() => { rawPointer.set(0); setHeld(false); }} className="relative w-full" style={{ height: stageHeight, visibility: stageWidth > 0 ? "visible" : "hidden" }} > {panels.map((panel, i) => ( <Panel key={i} panel={panel} index={i} active={i === active} opens={opens} pointer={pointer} vertical={vertical} narrow={narrow} wide={wide} gap={gap} rounding={rounding} parallax={strength} onSelect={select} buttonRef={(el) => { buttons.current[i] = el; }} /> ))} </div> </div> ); }
Adds the files to
components/uibrik/expanding-panels and installs motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/expanding-panels.Props
ExpandingPanels- defaultActive
- Type number
- 0
- Panel open to begin with, from 0.
- autoplay
- Type boolean
- true
- Open the next panel on its own. Holds while pointed at or focused, and with reduced motion.
- interval
- Type number
- 3000
- How long each panel stays open while playing.
- duration
- Type number
- 0.85
- How long a change takes. The move leaves at once and has a long, soft tail.
- parallax
- Type number
- 35
- How far images trail behind their panels as the row moves. 0 turns it off.
- collapsed
- Type number
- 64
- Width of a closed panel.
- gap
- Type number
- 5
- Space between panels.
- height
- Type number
- 420
- Height of the row. Narrow stages stack the panels and ignore this.
- rounding
- Type number
- 12
- Corner radius of a panel.
- panels
- Type { src: string; alt: string }[]
- 5 sample images
- The panels, in order.
- label
- Type string
- "Gallery"
- Names the group for screen readers.
- onChange
- Type (index: number) => void
- –
- Called with the newly open panel, from 0.
- className
- Type string
- –
- Classes for the wrapper.