Preview Accordion
An accordion whose rows show a one-line preview until you open them.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/preview-accordion.jsonDependencies
- motion
- lucide-react
Interaction
Each row shows an icon, a title and one line of preview. Opening a row collapses the preview, eases the full text in from a blur and turns the chevron. One row stays open at a time unless multiple is set.
Props
| Prop | Type | Description |
|---|---|---|
| items* | { id; title; preview; content; icon? }[] | Rows to show. |
| multiple | boolean | Allow more than one row open at once. |
| defaultOpen | string[] | Ids of rows that start open. |
| className | string | Extra classes merged onto the root element. |
How to use
import PreviewAccordion from "@/components/ui/preview-accordion"
export function Example() {
return (
<PreviewAccordion
items={[{ id: "a", title: "Title", preview: "One line", content: "Full text" }]}
/>
)
}Source code
components/ui/preview-accordion.tsx
"use client"
import { useId, useState, type ReactNode } from "react"
import { AnimatePresence, motion } from "motion/react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export interface PreviewAccordionItem {
id: string
title: string
/** One line shown under the title while the row is closed. */
preview: string
/** Full text shown when the row is open. */
content: ReactNode
icon?: ReactNode
}
export interface PreviewAccordionProps {
items: PreviewAccordionItem[]
/** Let more than one row stay open. Defaults to one at a time. */
multiple?: boolean
/** Ids that start open. */
defaultOpen?: string[]
className?: string
}
const ease = [0.32, 0.72, 0, 1] as const
export default function PreviewAccordion({ items, multiple = false, defaultOpen = [], className }: PreviewAccordionProps) {
const base = useId()
const [open, setOpen] = useState<string[]>(defaultOpen)
const toggle = (id: string) =>
setOpen((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : multiple ? [...cur, id] : [id]))
return (
<div className={cn("w-full max-w-md space-y-1 rounded-3xl border border-line bg-card p-1.5 shadow-sm", className)}>
{items.map((item) => {
const isOpen = open.includes(item.id)
const buttonId = `${base}-${item.id}-button`
const panelId = `${base}-${item.id}-panel`
return (
<div key={item.id} className={cn("rounded-2xl transition-colors duration-200", isOpen ? "bg-black/[0.045] dark:bg-white/[0.07]" : "hover:bg-black/[0.03] dark:hover:bg-white/[0.045]")}>
<button
type="button"
id={buttonId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(item.id)}
className="flex w-full items-start gap-3 rounded-2xl p-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-fg/30"
>
<span className="grid size-10 shrink-0 place-items-center rounded-xl border border-black/[0.06] bg-white text-muted shadow-sm dark:border-white/10 dark:bg-white/[0.06] dark:shadow-none">{item.icon}</span>
<span className="min-w-0 flex-1 pt-0.5">
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">{item.title}</span>
<AnimatePresence initial={false}>
{!isOpen && (
<motion.span
key="preview"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease }}
className="block overflow-hidden"
>
<span className="block truncate pt-0.5 text-xs text-neutral-500 dark:text-neutral-400">{item.preview}</span>
</motion.span>
)}
</AnimatePresence>
</span>
<motion.span animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.25, ease }} className="mt-1 text-neutral-400 dark:text-neutral-500">
<ChevronDown className="size-4" />
</motion.span>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={panelId}
role="region"
aria-labelledby={buttonId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease }}
className="overflow-hidden"
>
<motion.div
initial={{ filter: "blur(6px)", y: -4 }}
animate={{ filter: "blur(0px)", y: 0 }}
transition={{ duration: 0.3, delay: 0.05 }}
className="px-4 pb-4 text-sm leading-6 text-neutral-600 dark:text-neutral-300"
>
{item.content}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
)
}