Mikkel Manniche
Dansk English Deutsch

Widgets

List stack

Karten in einem Stapel, die sich beim Öffnen zu einer Liste auffächern.

Angepasst von Watermelon UI (MIT) und neu geschrieben mit Tastatur, Beschriftungen und reduzierter Bewegung.

Vorschau

Verwendung

list-stack-demo.tsx
import { PackageCheck, RotateCcw, Truck } from 'lucide-react'
import { ListStack } from '@/components/list-stack'

export default function ListStackDemo() {
  return (
    <ListStack
      showLabel="Show orders"
      items={[
        { id: 'a', title: 'Order #1042', detail: 'Out for delivery', meta: 'Today', icon: <Truck /> },
        { id: 'b', title: 'Order #1038', detail: 'Delivered to the front door', meta: '2 Aug', icon: <PackageCheck /> },
        { id: 'c', title: 'Order #1031', detail: 'Return received, refund sent', meta: '28 Jul', icon: <RotateCcw /> },
      ]}
    />
  )
}

Quellcode

list-stack.tsx
// Based on Watermelon UI's “List stack” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten so the list keeps its height, reads in order and opens with a real button.
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useId, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type ListStackItem = { id: string; title: string; detail?: string; meta?: string; icon?: ReactNode }

export type ListStackProps = {
  items: ListStackItem[]
  showLabel?: string
  hideLabel?: string
  className?: string
}

const ROW = 64
const GAP = 8

/** A short list folded into a stack of cards. Open it and the cards fan out into rows. */
export function ListStack({ items, showLabel = 'Show all', hideLabel = 'Hide', className }: ListStackProps) {
  const [open, setOpen] = useState(false)
  const id = useId()
  const n = items.length
  const height = open ? n * (ROW + GAP) : ROW + 2 * 8

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 200, damping: 24 }}>
      <div className={cn('mx-auto flex w-full max-w-xs flex-col items-center gap-3', className)}>
        <motion.ul id={id} initial={false} animate={{ height }} className="relative w-full [perspective:1000px]">
          {items.map((it, i) => {
            // The first item sits on top of the stack; the rest tuck in behind it, smaller.
            const depth = i
            return (
              <motion.li
                key={it.id}
                initial={false}
                animate={
                  open
                    ? { y: i * (ROW + GAP), scale: 1, opacity: 1 }
                    : { y: Math.min(depth, 2) * 8, scale: 1 - Math.min(depth, 3) * 0.05, opacity: depth > 2 ? 0 : 1 }
                }
                style={{ height: ROW, zIndex: n - i, transformOrigin: 'top center' }}
                aria-hidden={!open && i > 0}
                className="absolute inset-x-0 top-0 flex items-center gap-3 rounded-2xl border bg-card px-3 shadow-sm"
              >
                {it.icon && (
                  <span className="grid size-10 shrink-0 place-items-center rounded-xl bg-foreground text-background [&_svg]:size-5">{it.icon}</span>
                )}
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium">{it.title}</span>
                  {it.detail && <span className="block truncate text-xs text-muted-foreground">{it.detail}</span>}
                </span>
                {it.meta && <span className="shrink-0 self-end pb-3 text-xs text-muted-foreground">{it.meta}</span>}
              </motion.li>
            )
          })}
        </motion.ul>
        <motion.button
          layout
          type="button"
          aria-expanded={open}
          aria-controls={id}
          onClick={() => setOpen((o) => !o)}
          className="min-h-11 overflow-hidden rounded-full border bg-card px-5 text-sm font-medium shadow-sm"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span key={String(open)} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} className="block">
              {open ? hideLabel : `${showLabel} (${n})`}
            </motion.span>
          </AnimatePresence>
        </motion.button>
      </div>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/list-stack.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/list-stack

Abhängigkeiten

Dateien

  • list-stack.tsx

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.