Mikkel Manniche
Dansk English Deutsch

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.json

With the registry added (components.json)

npx shadcn@latest add @manniche/accordion-gallery

Dependencies

Files

  • accordion-gallery.tsx

Manniche Lab · open source

Free tools for AI agents and web development.