shadimage

Accordion

Expanding accordion rows — each reveals its body, a checklist, and a visual.

components/ui/accordion.tsx

"use client"

import { useState, type ComponentType, type ReactNode } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown, Check } from "lucide-react"
import { cn } from "@/lib/utils"

// transitions.dev signature smooth-out easing (cubic-bezier(0.22, 1, 0.36, 1)).
const EASE = [0.22, 1, 0.36, 1] as const

export type AccordionItem = {
  title: string
  body: string
  icon?: ComponentType<{ className?: string }>
  deliverables?: string[]
}

/**
 * Accordion (WAI-ARIA) — a vertical list of titles; the active row expands to
 * reveal its body + deliverables alongside a visual supplied by `stage(i)`.
 * Pulled from the /pro-services "Variant 02 · accordion".
 * 2026-10-02: surfaces at 7% (raised blocks white in light, 12% in dark) so cards read on white and on black.
 */
export function Accordion({
  items = [],
  stage,
  defaultOpen = 0,
}: {
  items?: AccordionItem[]
  stage?: (i: number) => ReactNode
  defaultOpen?: number
}) {
  const [active, setActive] = useState(defaultOpen)
  const reduced = useReducedMotion()

  return (
    <div className="w-full">
      {items.map((it, i) => {
        const on = active === i
        const Icon = it.icon
        return (
          <div key={i} className="border-t border-foreground/[0.08] first:border-t-0">
            <h3>
              <button
                type="button"
                id={`acc-tab-${i}`}
                aria-expanded={on}
                aria-controls={`acc-panel-${i}`}
                onClick={() => setActive(on ? -1 : i)}
                className={cn(
                  "flex w-full cursor-pointer items-center justify-between gap-4 pt-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-foreground/40",
                  on ? "pb-2" : "pb-4"
                )}
              >
                <span className="flex items-center gap-3">
                  {Icon && (
                    <Icon
                      className={cn(
                        "h-5 w-5 shrink-0 text-foreground transition-opacity duration-300",
                        on ? "opacity-100" : "opacity-35"
                      )}
                    />
                  )}
                  <span
                    className={cn(
                      "text-[22px] font-bold leading-[1.1] transition-colors duration-300",
                      on ? "text-foreground" : "text-foreground/40 hover:text-foreground/60"
                    )}
                  >
                    {it.title}
                  </span>
                </span>
                {/* chevron flips vertically (scaleY) — a "v" through a flat line to a "^" */}
                <motion.span
                  aria-hidden
                  className="inline-flex shrink-0"
                  animate={{ scaleY: on ? -1 : 1 }}
                  transition={reduced ? { duration: 0 } : { duration: 0.25, ease: EASE }}
                >
                  <ChevronDown className={cn("h-5 w-5", on ? "text-foreground" : "text-foreground/40")} />
                </motion.span>
              </button>
            </h3>

            <AnimatePresence initial={false}>
              {on && (
                <motion.section
                  key="panel"
                  id={`acc-panel-${i}`}
                  role="region"
                  aria-labelledby={`acc-tab-${i}`}
                  initial={{ height: 0 }}
                  animate={{ height: "auto" }}
                  exit={{ height: 0 }}
                  transition={reduced ? { duration: 0 } : { duration: 0.28, ease: EASE }}
                  className="overflow-hidden"
                >
                  {/* body rises out of a soft blur as the panel grows */}
                  <motion.div
                    initial={{ opacity: 0, filter: reduced ? "none" : "blur(2px)" }}
                    animate={{ opacity: 1, filter: reduced ? "none" : "blur(0px)" }}
                    exit={{ opacity: 0, filter: reduced ? "none" : "blur(2px)" }}
                    transition={reduced ? { duration: 0 } : { duration: 0.28, ease: EASE }}
                    className="grid grid-cols-1 gap-6 pb-6 lg:grid-cols-2 lg:items-center lg:gap-10"
                  >
                    {/* left — body + deliverables */}
                    <div>
                      <p className="max-w-xl text-[14px] font-light leading-[1.55] text-foreground/50">{it.body}</p>
                      {it.deliverables && it.deliverables.length > 0 && (
                        <ul className="mt-5 flex flex-col gap-2.5">
                          {it.deliverables.map((d) => (
                            <li key={d} className="flex items-start gap-2.5 text-[13px] leading-snug text-foreground/55">
                              <span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-background shadow-[0_0_0_1px_var(--light-outline,color-mix(in_srgb,var(--foreground)_9%,transparent)),0_1px_2px_rgba(0,0,0,0.04)] dark:bg-[color-mix(in_srgb,var(--foreground)_7%,var(--background))] dark:shadow-none">
                                <Check className="h-4 w-4 text-foreground opacity-70" />
                              </span>
                              <span>{d}</span>
                            </li>
                          ))}
                        </ul>
                      )}
                    </div>

                    {/* right — visual */}
                    {stage && <div className="relative">{stage(i)}</div>}
                  </motion.div>
                </motion.section>
              )}
            </AnimatePresence>
          </div>
        )
      })}
    </div>
  )
}
Interaction
scroll-revealaccordion

DependenciesBundle weight · medium

icons
lucide-react
animation
motion