nucleusUI
All components

NucleusAccordion

NavigationNew

An animated accordion with an expanding chevron, spring height transitions and single or multiple open modes.

Preview

No. MIT licensed means the code is yours once you copy it. No attribution needed, no license headers to keep.

Installation

  1. 1shadcn/ui initialized (`npx shadcn@latest init`)
  2. 2Tailwind CSS v4 configured with the nucleusUI theme tokens
npm install lucide-react clsx tailwind-merge framer-motion

Add to your globals.css

The nucleusUI accent lives in these CSS variables. Without them the component falls back to the default shadcn zinc theme.

app/globals.css
:root {
  --primary: oklch(0.54 0.24 291);
  --primary-foreground: oklch(0.985 0 0);
  --ring: oklch(0.54 0.24 291);
  --accent: oklch(0.96 0.03 291);
  --accent-foreground: oklch(0.4 0.19 291);
}

.dark {
  --primary: oklch(0.68 0.21 291);
  --primary-foreground: oklch(0.145 0 0);
  --ring: oklch(0.68 0.21 291);
  --accent: oklch(0.26 0.06 291);
  --accent-foreground: oklch(0.87 0.09 291);
}

Usage

tsx
import { NucleusAccordion } from "@/components/nucleus/nucleus-accordion";

const items = [
  { value: "one", title: "First question", content: <p>The answer.</p> },
  { value: "two", title: "Second question", content: <p>Another answer.</p> },
];

export function Example() {
  return <NucleusAccordion items={items} multiple defaultValue="one" />;
}

Source

Copy this file into components/nucleus/nucleus-accordion.tsx in your project, it's yours to own and modify.

components/nucleus/nucleus-accordion.tsx
"use client";

import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

export interface NucleusAccordionItemData {
  /** Unique value used to identify the open item. */
  value: string;
  /** Row title. */
  title: string;
  /** Collapsed content. */
  content: React.ReactNode;
}

export interface NucleusAccordionProps {
  items: NucleusAccordionItemData[];
  /** Allow more than one item open at once. Default true. */
  multiple?: boolean;
  /** Value opened on first render. */
  defaultValue?: string;
  className?: string;
}

/**
 * Simple animated accordion. Items expand with spring physics and the
 * chevron rotates in place. Keyboard accessible with native buttons.
 */
export function NucleusAccordion({
  items,
  multiple = true,
  defaultValue,
  className,
}: NucleusAccordionProps) {
  const [open, setOpen] = React.useState<string[]>(
    defaultValue ? [defaultValue] : []
  );

  const toggle = (value: string) => {
    setOpen((prev) => {
      const isOpen = prev.includes(value);
      if (multiple) {
        return isOpen ? prev.filter((v) => v !== value) : [...prev, value];
      }
      return isOpen ? [] : [value];
    });
  };

  return (
    <div
      data-slot="nucleus-accordion"
      className={cn("flex w-full flex-col", className)}
    >
      {items.map((item) => {
        const isOpen = open.includes(item.value);
        return (
          <div key={item.value} className="border-b border-border/70 last:border-b-0">
            <button
              type="button"
              aria-expanded={isOpen}
              aria-controls={`panel-${item.value}`}
              onClick={() => toggle(item.value)}
              className="flex w-full cursor-pointer items-center justify-between gap-4 py-4 text-left text-sm font-medium transition-colors hover:text-primary"
            >
              {item.title}
              <motion.span
                animate={{ rotate: isOpen ? 180 : 0 }}
                transition={{ type: "spring", stiffness: 260, damping: 22 }}
                className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground"
              >
                <ChevronDown className="size-3.5" />
              </motion.span>
            </button>
            <AnimatePresence initial={false}>
              {isOpen && (
                <motion.div
                  id={`panel-${item.value}`}
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{ height: 0, opacity: 0 }}
                  transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
                  className="overflow-hidden"
                >
                  <div className="pb-4 text-sm leading-relaxed text-muted-foreground">
                    {item.content}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        );
      })}
    </div>
  );
}

Props

PropTypeDescription
items{ value: string; title: string; content: ReactNode }[]Accordion rows: id, title and collapsible content.
multiplebooleanAllow more than one item open at once.
defaultValuestringItem value opened on first render.
classNamestringExtra classes for the root element.