makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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

Dependencies

  • 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

PropTypeDescription
items*{ id; title; preview; content; icon? }[]Rows to show.
multiplebooleanAllow more than one row open at once.
defaultOpenstring[]Ids of rows that start open.
classNamestringExtra 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>
  )
}

On this page