Surfaces
Accordion gallery
A rack of modules where one opens to a full card and the rest close to labelled strips; only transforms move, keyboard and touch included.
Preview
Usage
accordion-gallery-demo.tsx
import type { ReactNode } from 'react'
import { AccordionGallery, type AccordionGalleryItem } from '@/components/accordion-gallery'
// A small seeded random generator, so the drawings are the same on every render and on the server.
function rng(seed: number) {
return () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// A closed curve through the midpoints of the given points.
function smooth(pts: [number, number][]) {
const n = pts.length
const mid = (a: [number, number], b: [number, number]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
const s = mid(pts[n - 1], pts[0])
let d = `M${s[0].toFixed(1)} ${s[1].toFixed(1)}`
for (let i = 0; i < n; i++) {
const p = pts[i]
const q = mid(p, pts[(i + 1) % n])
d += `Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${q[0].toFixed(1)} ${q[1].toFixed(1)}`
}
return d + 'Z'
}
// The drawing's pens, from the theme tokens.
const grid = 'fill-none stroke-foreground/14 [stroke-width:1]'
const fg = 'fill-none stroke-foreground [stroke-width:1.25]'
const mid = 'fill-none stroke-muted-foreground [stroke-width:1]'
const sig = 'fill-none stroke-primary [stroke-width:2] [stroke-linecap:round] [stroke-linejoin:round]'
const dot = 'fill-muted-foreground'
const dotp = 'fill-primary'
function Art({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 480 360" preserveAspectRatio="xMidYMid slice" aria-hidden>
{children}
</svg>
)
}
function Wave() {
let g = ''
for (let x = 0; x <= 480; x += 30) g += `M${x} 0V360`
for (let y = 0; y <= 360; y += 30) g += `M0 ${y}H480`
let a = ''
let b = ''
for (let x = 0; x <= 480; x += 3) {
const t = (x / 480) * Math.PI * 2
const y = 170 + Math.sin(t * 3) * 52 * Math.exp(-x / 520) + Math.sin(t * 11) * 9
const z = 170 + Math.sin(t * 3 + 1.2) * 34
a += (x ? 'L' : 'M') + x + ' ' + y.toFixed(1)
b += (x ? 'L' : 'M') + x + ' ' + z.toFixed(1)
}
return (
<Art>
<path className={grid} d={g} />
<path className={mid} d="M0 170H480M240 0V360" />
<path className={mid} d={b} strokeDasharray="3 4" />
<path className={sig} d={a} />
</Art>
)
}
function Dial() {
const cx = 240
const cy = 200
const R = 132
let t = ''
for (let k = 0; k <= 60; k++) {
const a = Math.PI * (0.8 + (k / 60) * 1.4)
const r1 = R - (k % 10 === 0 ? 22 : k % 5 === 0 ? 14 : 8)
const c = Math.cos(a)
const s = Math.sin(a)
t += `M${(cx + c * R).toFixed(1)} ${(cy + s * R).toFixed(1)}L${(cx + c * r1).toFixed(1)} ${(cy + s * r1).toFixed(1)}`
}
const na = Math.PI * (0.8 + 0.62 * 1.4)
return (
<Art>
{[40, 86].map((r) => (
<circle key={r} className={grid} cx={cx} cy={cy} r={r} />
))}
<circle className={mid} cx={cx} cy={cy} r={R + 10} />
<path className={fg} d={t} />
<path className={sig} d={`M${cx} ${cy}L${(cx + Math.cos(na) * (R - 30)).toFixed(1)} ${(cy + Math.sin(na) * (R - 30)).toFixed(1)}`} />
<circle className="fill-card stroke-foreground/14" cx={cx} cy={cy} r="14" />
<circle className={dotp} cx={cx} cy={cy} r="3" />
</Art>
)
}
function Contour() {
const rand = rng(5)
const harm = [2, 3, 4, 6].map((f, j) => ({ f, a: (0.12 / (j + 1)) * (0.6 + rand()), p: rand() * 6.28 }))
const rings = Array.from({ length: 16 }, (_, k) => {
const r0 = 22 + k * 15
const pts: [number, number][] = []
for (let s = 0; s < 100; s++) {
const th = (s / 100) * Math.PI * 2
let m = 1
harm.forEach((q) => {
m += q.a * Math.sin(q.f * th + q.p + k * 0.07)
})
pts.push([250 + Math.cos(th) * r0 * m * 1.15, 175 + Math.sin(th) * r0 * m * 0.85])
}
return smooth(pts)
})
return (
<Art>
{rings.map((d, k) => (
<path key={k} d={d} className={k === 6 ? sig : k % 4 === 0 ? fg : mid} />
))}
</Art>
)
}
function Lattice() {
const dots = []
for (let y = 18; y < 360; y += 24)
for (let x = 18; x < 480; x += 24) {
const d = Math.hypot(x - 250, y - 170)
const on = Math.abs(y - 30 - (x - 90) * 0.55) < 12 && x > 80 && x < 420
dots.push(<circle key={`${x}-${y}`} className={on ? dotp : dot} cx={x} cy={y} r={on ? 3.4 : d < 120 ? 2.2 : 1.3} opacity={!on && d >= 120 ? 0.55 : undefined} />)
}
return <Art>{dots}</Art>
}
function Spectrum() {
return (
<Art>
<path className={grid} d="M0 300H480M0 240H480M0 180H480M0 120H480M0 60H480" />
{Array.from({ length: 34 }, (_, k) => {
const x = (20 + k * 13.2).toFixed(1)
const h = 40 + 150 * Math.abs(Math.sin(k * 0.37) * Math.cos(k * 0.11)) + 30 * Math.sin(k * 1.7) ** 2
return (
<g key={k}>
<rect className="fill-[color-mix(in_oklab,var(--foreground)_72%,var(--muted))]" x={x} y={(300 - h).toFixed(1)} width="8" height={h.toFixed(1)} rx="1" />
<path className={sig} d={`M${x} ${(300 - h - 10).toFixed(1)}h8`} />
</g>
)
})}
</Art>
)
}
const items: AccordionGalleryItem[] = [
{ title: 'Waveform', description: 'A drawn trace on a ruled grid.', content: <Wave /> },
{ title: 'Dial', description: 'A sweep dial with an engraved scale.', content: <Dial /> },
{ title: 'Contour', description: 'Contour lines from one noise field.', content: <Contour /> },
{ title: 'Lattice', description: 'A dot lattice with one marked path.', content: <Lattice /> },
{ title: 'Spectrum', description: 'Bars from a fixed sine sum, not data.', content: <Spectrum /> },
].map((item, k) => ({ ...item, eyebrow: `slot ${k + 1} · demo` }))
export default function AccordionGalleryDemo() {
return <AccordionGallery items={items} defaultIndex={1} />
}Source
accordion-gallery.tsx
import { useEffect, useId, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type AccordionGalleryItem = {
/** A stable key. Falls back to the position. */
id?: string
/** The module's name, on its narrow strip and in its caption. */
title: ReactNode
/** A short line in the caption card. */
description?: ReactNode
/** A small mono line above the title in the caption card. */
eyebrow?: ReactNode
/** What fills the open module: an img or an SVG fills it edge to edge. */
content: ReactNode
}
export type AccordionGalleryProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
/** The modules, in order. */
items: AccordionGalleryItem[]
/** Controlled: the open module. */
index?: number
/** The module open at first, when uncontrolled. */
defaultIndex?: number
/** Called when a module opens. */
onIndexChange?: (index: number) => void
/** Open a module when a mouse rests on its strip. Touch and pen open on tap. */
openOnHover?: boolean
/** The heading level of each strip, so it fits the page's outline. */
headingLevel?: 2 | 3 | 4 | 5 | 6
/** `auto` stacks the modules vertically when the gallery is narrower than 520 px. */
orientation?: 'auto' | 'horizontal' | 'vertical'
}
const S = 56 // a closed module's strip
/**
* A rack of modules on one axis. One is open as a full card; the others close to 56 px strips with a vertical label.
* Every module has the open module's size, and opening one only slides the modules after it along, so only transform
* and opacity animate. Hover, tap, focus or the arrow keys open a module (an accordion with one panel always open).
* Under reduced motion the modules and the caption switch without travel.
*/
export function AccordionGallery({
items,
index,
defaultIndex = 0,
onIndexChange,
openOnHover = true,
headingLevel = 3,
orientation = 'auto',
className,
style,
...rest
}: AccordionGalleryProps) {
const n = items.length
const [own, setOwn] = useState(defaultIndex)
const cur = Math.min(Math.max(index ?? own, 0), Math.max(n - 1, 0))
const id = useId()
const tabs = useRef<(HTMLButtonElement | null)[]>([])
const hover = useRef(0)
useEffect(() => () => clearTimeout(hover.current), [])
const select = (k: number) => {
clearTimeout(hover.current)
if (k === cur) return
setOwn(k)
onIndexChange?.(k)
}
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, k: number) => {
const to = { ArrowRight: k + 1, ArrowDown: k + 1, ArrowLeft: k - 1, ArrowUp: k - 1, Home: 0, End: n - 1 }[e.key]
if (to === undefined) return
e.preventDefault()
// Moving focus opens the module, through its focus handler.
tabs.current[(to + n) % n]?.focus()
}
return (
<div
data-orientation={orientation}
style={{ '--ag-n': n, ...style } as CSSProperties}
className={cn('group/ag w-full', orientation === 'auto' && '@container/ag', className)}
{...rest}
>
<div
className={cn(
'relative h-[372px] w-full overflow-hidden rounded-[calc(var(--radius)*2+2px)] bg-card shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]',
'@max-[520px]/ag:h-[calc(300px+(var(--ag-n)-1)*56px)] group-data-[orientation=vertical]/ag:h-[calc(300px+(var(--ag-n)-1)*56px)]',
)}
>
{items.map((item, k) => {
const on = k === cur
const tab = `${id}-t${k}`
const panel = `${id}-p${k}`
// Modules up to the open one stack from the start; the rest sit one open module further on.
const after = k > cur
const offset = `calc(${after ? '100% + ' : ''}${(after ? k - 1 : k) * S}px)`
return (
<div
key={item.id ?? k}
data-on={on ? '' : undefined}
style={{ zIndex: k + 1, '--ag-p': offset } as CSSProperties}
className={cn(
'group/m absolute top-0 left-0 flex h-full w-[calc(100%-(var(--ag-n)-1)*56px)] bg-card',
'[transform:translateX(var(--ag-p))] transition-transform duration-280 ease-out-quint will-change-transform motion-reduce:transition-none',
k > 0 &&
'shadow-[-1px_0_0_color-mix(in_oklab,var(--foreground)_9%,transparent),-14px_0_22px_-20px_rgb(0_0_0/0.28)] dark:shadow-[-1px_0_0_color-mix(in_oklab,var(--foreground)_9%,transparent),-14px_0_22px_-20px_rgb(0_0_0/0.7)]',
'@max-[520px]/ag:h-[300px] @max-[520px]/ag:w-full @max-[520px]/ag:flex-col @max-[520px]/ag:[transform:translateY(var(--ag-p))]',
'group-data-[orientation=vertical]/ag:h-[300px] group-data-[orientation=vertical]/ag:w-full group-data-[orientation=vertical]/ag:flex-col group-data-[orientation=vertical]/ag:[transform:translateY(var(--ag-p))]',
k > 0 &&
'@max-[520px]/ag:shadow-[0_-1px_0_var(--border),0_-14px_22px_-20px_rgb(0_0_0/0.28)] @max-[520px]/ag:dark:shadow-[0_-1px_0_var(--border),0_-14px_22px_-20px_rgb(0_0_0/0.7)]',
k > 0 &&
'group-data-[orientation=vertical]/ag:shadow-[0_-1px_0_var(--border),0_-14px_22px_-20px_rgb(0_0_0/0.28)] group-data-[orientation=vertical]/ag:dark:shadow-[0_-1px_0_var(--border),0_-14px_22px_-20px_rgb(0_0_0/0.7)]',
)}
>
<div
role="heading"
aria-level={headingLevel}
className="h-full w-14 shrink-0 @max-[520px]/ag:h-14 @max-[520px]/ag:w-full group-data-[orientation=vertical]/ag:h-14 group-data-[orientation=vertical]/ag:w-full"
>
<button
ref={(el) => {
tabs.current[k] = el
}}
type="button"
id={tab}
aria-expanded={on}
aria-controls={panel}
// The open module cannot close: one is always open.
aria-disabled={on || undefined}
onClick={() => select(k)}
onFocus={() => select(k)}
onKeyDown={(e) => onKeyDown(e, k)}
onPointerEnter={(e) => {
if (!openOnHover || e.pointerType !== 'mouse') return
clearTimeout(hover.current)
hover.current = window.setTimeout(() => select(k), 70)
}}
onPointerLeave={() => clearTimeout(hover.current)}
className={cn(
'group/t flex size-full cursor-pointer flex-col items-center justify-between bg-card py-3.5 text-muted-foreground [-webkit-tap-highlight-color:transparent] aria-disabled:cursor-default',
'focus-visible:rounded-[calc(var(--radius)+2px)] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring',
'@max-[520px]/ag:flex-row @max-[520px]/ag:px-3.5 @max-[520px]/ag:py-0 group-data-[orientation=vertical]/ag:flex-row group-data-[orientation=vertical]/ag:px-3.5 group-data-[orientation=vertical]/ag:py-0',
)}
>
<span
aria-hidden
className="grid h-7 min-w-7 shrink-0 place-items-center rounded-full bg-muted px-1.5 font-mono text-[11px] leading-none font-medium text-muted-foreground tabular-nums group-hover/t:text-foreground group-data-on/m:bg-foreground group-data-on/m:text-card"
>
{String(k + 1).padStart(2, '0')}
</span>
<span className="flex flex-1 flex-col items-center gap-3.5 pt-3 pb-3 @max-[520px]/ag:flex-row @max-[520px]/ag:gap-3 @max-[520px]/ag:py-0 @max-[520px]/ag:pl-3.5 group-data-[orientation=vertical]/ag:flex-row group-data-[orientation=vertical]/ag:gap-3 group-data-[orientation=vertical]/ag:py-0 group-data-[orientation=vertical]/ag:pl-3.5">
<span
className={cn(
'mt-auto mb-auto rotate-180 text-[15px] leading-none font-medium tracking-[-0.01em] whitespace-nowrap [writing-mode:vertical-rl] group-hover/t:text-foreground group-data-on/m:text-foreground',
'@max-[520px]/ag:mt-0 @max-[520px]/ag:mb-0 @max-[520px]/ag:rotate-0 @max-[520px]/ag:[writing-mode:horizontal-tb] group-data-[orientation=vertical]/ag:mt-0 group-data-[orientation=vertical]/ag:mb-0 group-data-[orientation=vertical]/ag:rotate-0 group-data-[orientation=vertical]/ag:[writing-mode:horizontal-tb]',
)}
>
{item.title}
</span>
</span>
<span
aria-hidden
className="relative size-2 shrink-0 rounded-full bg-muted @max-[520px]/ag:mr-3.5 @max-[520px]/ag:ml-auto group-data-[orientation=vertical]/ag:mr-3.5 group-data-[orientation=vertical]/ag:ml-auto"
>
<span className="absolute inset-0 rounded-full bg-primary opacity-0 transition-opacity duration-120 ease-out-quint group-data-on/m:opacity-100 motion-reduce:transition-none" />
</span>
</button>
</div>
<div
id={panel}
role="region"
aria-labelledby={tab}
inert={!on}
className="relative my-1.5 mr-1.5 min-h-0 min-w-0 flex-1 overflow-hidden rounded-[calc(var(--radius)+2px)] bg-muted @max-[520px]/ag:mt-0 @max-[520px]/ag:ml-1.5 group-data-[orientation=vertical]/ag:mt-0 group-data-[orientation=vertical]/ag:ml-1.5"
>
<div className="absolute inset-0 [&>img]:size-full [&>img]:object-cover [&>svg]:size-full">{item.content}</div>
{(item.description || item.eyebrow) && (
<div
className={cn(
'absolute right-3 bottom-3 left-3 grid max-w-[300px] translate-y-1.5 gap-1 rounded-[calc(var(--radius)+2px)] bg-card px-4 py-3.5 opacity-0 shadow-[0_1px_2px_rgba(0,0,0,0.14),0_10px_24px_-10px_rgba(0,0,0,0.4)]',
'transition-[opacity,translate] ease-out-quint [transition-duration:180ms,220ms] motion-reduce:transition-none',
'group-data-on/m:translate-y-0 group-data-on/m:opacity-100 group-data-on/m:delay-90',
)}
>
{item.eyebrow && (
<span className="font-mono text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground tabular-nums">{item.eyebrow}</span>
)}
<b className="text-[15px] leading-[1.3] font-semibold tracking-[-0.01em]">{item.title}</b>
{item.description && <span className="text-[12.5px] leading-[1.45] text-muted-foreground">{item.description}</span>}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/accordion-gallery.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/accordion-gallery