# Accordion

> Collapsible sections with smooth height animation.

Category: Data Display. Docs: https://get-neonkit.vercel.app/components/accordion

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/accordion.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/accordion`.

npm dependencies: `motion`, `lucide-react`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"

<Accordion defaultValue="free">
  <AccordionItem value="free">
    <AccordionTrigger>Is NeonKit free?</AccordionTrigger>
    <AccordionContent>Yes. Every component is MIT licensed.</AccordionContent>
  </AccordionItem>
</Accordion>
```

## Examples

### FAQ

One item at a time. A thin line of light marks the open one.

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";

const faqs = [
  {
    value: "free",
    question: "Is NeonKit free?",
    answer:
      "Yes. Every component is MIT licensed. Use it in personal and commercial projects, change whatever you like, and ship it without attribution.",
  },
  {
    value: "tailwind",
    question: "Do I need Tailwind CSS 4?",
    answer:
      "No. The components avoid utilities that were renamed between versions, so the same file works in Tailwind 3.4 and 4. Colors live in arbitrary values, so there is nothing to add to your config.",
  },
  {
    value: "color",
    question: "How do I change the color?",
    answer:
      "Pass color to any component, either a palette name like \"magenta\" or a hex value. To recolor a whole page or section, set --neon-accent on a parent element and every component inside follows it.",
  },
  {
    value: "shadcn",
    question: "Can I use it with shadcn/ui?",
    answer:
      "Yes. The registry is shadcn-compatible, so the shadcn CLI pulls a component and its dependencies into the same components/ui folder as your existing shadcn components.",
  },
];

export default function AccordionDemo() {
  return (
    <Accordion type="single" defaultValue="free" className="max-w-lg">
      {faqs.map((faq) => (
        <AccordionItem key={faq.value} value={faq.value}>
          <AccordionTrigger>{faq.question}</AccordionTrigger>
          <AccordionContent>{faq.answer}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  );
}
```

### Multiple

type="multiple" lets any number of sections stay open, like a filter panel.

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";

const groups = [
  {
    value: "type",
    title: "Job type",
    options: [
      { label: "Full-time", count: 42, checked: true },
      { label: "Contract", count: 18, checked: true },
      { label: "Part-time", count: 7, checked: false },
    ],
  },
  {
    value: "location",
    title: "Location",
    options: [
      { label: "Remote", count: 51, checked: true },
      { label: "Lisbon", count: 9, checked: false },
      { label: "Tokyo", count: 6, checked: false },
      { label: "Berlin", count: 4, checked: false },
    ],
  },
  {
    value: "level",
    title: "Level",
    options: [
      { label: "Mid", count: 21, checked: false },
      { label: "Senior", count: 33, checked: false },
      { label: "Staff", count: 8, checked: false },
    ],
  },
];

export default function AccordionMultiple() {
  return (
    <Accordion type="multiple" defaultValue={["type", "location"]} className="max-w-xs">
      {groups.map((group) => (
        <AccordionItem key={group.value} value={group.value}>
          <AccordionTrigger>{group.title}</AccordionTrigger>
          <AccordionContent className="grid gap-2.5 pr-0">
            {group.options.map((option) => (
              <label key={option.label} className="flex cursor-pointer items-center gap-2.5 text-white/70">
                <input
                  type="checkbox"
                  defaultChecked={option.checked}
                  className="size-3.5 shrink-0 cursor-pointer accent-[rgb(var(--neon))]"
                />
                <span className="flex-1">{option.label}</span>
                <span className="tabular-nums text-xs text-white/35">{option.count}</span>
              </label>
            ))}
          </AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  );
}
```

### Controlled

Drive value from state to expand or collapse everything at once.

```tsx
"use client";

import * as React from "react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";

const steps = [
  {
    value: "dependencies",
    title: "1. Install the dependencies",
    body: "Only the packages a component imports. For Accordion that is motion and lucide-react.",
    code: "npm install motion lucide-react",
  },
  {
    value: "helper",
    title: "2. Add the cn() helper",
    body: "Every component merges classes with clsx and tailwind-merge through a tiny cn() function.",
    code: "npm install clsx tailwind-merge",
  },
  {
    value: "component",
    title: "3. Copy the component",
    body: "Paste the source into components/ui/accordion.tsx, or let the shadcn CLI do it for you.",
    code: "npx shadcn@latest add https://get-neonkit.vercel.app/r/accordion.json",
  },
];

export default function AccordionControlled() {
  const [open, setOpen] = React.useState<string[]>(["dependencies"]);
  const allOpen = open.length === steps.length;

  return (
    <div className="grid w-full max-w-lg gap-3">
      <div className="flex justify-end">
        <Button variant="ghost" size="sm" onClick={() => setOpen(allOpen ? [] : steps.map((step) => step.value))}>
          {allOpen ? "Collapse all" : "Expand all"}
        </Button>
      </div>
      <Accordion type="multiple" value={open} onValueChange={setOpen}>
        {steps.map((step) => (
          <AccordionItem key={step.value} value={step.value}>
            <AccordionTrigger>{step.title}</AccordionTrigger>
            <AccordionContent className="grid gap-3">
              <p>{step.body}</p>
              <code className="block overflow-x-auto whitespace-nowrap rounded-lg border border-white/10 bg-[#0a0b11] px-3 py-2 font-mono text-xs text-white/80">
                {step.code}
              </code>
            </AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  );
}
```

## API reference

### Accordion

Renders a div and accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `"single" \| "multiple"` | `"single"` | How many items can be open at once. |
| `defaultValue` | `string \| string[]` | — | Initially open item(s) when uncontrolled. A string for single, an array for multiple. |
| `value` | `string \| string[]` | — | Open item(s) when controlled. "" means none in single mode. |
| `onValueChange` | `(value: string \| string[]) => void` | — | Called with the new value, typed to match type. |
| `collapsible` | `boolean` | `true` | Single only. Lets the open item close again. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### AccordionItem

Renders a div. Items are separated by hairlines.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | — | Unique id for the item. |
| `disabled` | `boolean` | `false` | Locks the item in its current state. |

### AccordionTrigger, AccordionContent

The trigger is a button inside an h3; the content is a div that animates its height.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | — | Merged with the defaults. |

## Accessibility

- Each trigger is a real button with aria-expanded and aria-controls, wrapped in an h3.
- Content has role="region" and is labelled by its trigger.
- Up and Down arrows move between triggers; Home and End jump to the first and last.
- When collapsible is false, the open trigger reports aria-disabled so screen readers know it cannot close.
- Height and opacity animations are skipped when the user prefers reduced motion.

## Source

`components/ui/accordion.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type AccordionContextValue = {
  open: string[];
  toggle: (value: string) => void;
  /** True for the open item of a single, non-collapsible accordion. */
  isLocked: (value: string) => boolean;
  reduceMotion: boolean;
};

type AccordionItemContextValue = {
  value: string;
  open: boolean;
  disabled: boolean;
  triggerId: string;
  contentId: string;
};

const AccordionContext = React.createContext<AccordionContextValue | null>(null);
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);

function useAccordion(component: string) {
  const context = React.useContext(AccordionContext);
  if (!context) throw new Error(`<${component}> must be used inside <Accordion>.`);
  return context;
}

function useAccordionItem(component: string) {
  const context = React.useContext(AccordionItemContext);
  if (!context) throw new Error(`<${component}> must be used inside <AccordionItem>.`);
  return context;
}

type AccordionBaseProps = Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "dir"> & {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
};

export type AccordionSingleProps = AccordionBaseProps & {
  /** One item open at a time. */
  type?: "single";
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Lets the open item close again. */
  collapsible?: boolean;
};

export type AccordionMultipleProps = AccordionBaseProps & {
  /** Any number of items open at once. */
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  collapsible?: never;
};

export type AccordionProps = AccordionSingleProps | AccordionMultipleProps;

type AccordionImplProps = AccordionBaseProps & {
  type?: "single" | "multiple";
  value?: string | string[];
  defaultValue?: string | string[];
  onValueChange?: (value: never) => void;
  collapsible?: boolean;
};

const toArray = (value: string | string[]) => (Array.isArray(value) ? value : value ? [value] : []);

const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>((props, ref) => {
  const {
    className,
    style,
    color,
    type = "single",
    value: valueProp,
    defaultValue,
    onValueChange,
    collapsible = true,
    onKeyDown,
    ...rest
  }: AccordionImplProps = props;

  const reduceMotion = useReducedMotion() ?? false;
  const [uncontrolled, setUncontrolled] = React.useState(() => toArray(defaultValue ?? []));
  const open = valueProp !== undefined ? toArray(valueProp) : uncontrolled;

  function toggle(item: string) {
    const isOpen = open.includes(item);
    let next: string[];
    if (type === "multiple") {
      next = isOpen ? open.filter((value) => value !== item) : [...open, item];
    } else {
      if (isOpen && !collapsible) return;
      next = isOpen ? [] : [item];
    }
    if (valueProp === undefined) setUncontrolled(next);
    (onValueChange as ((value: string | string[]) => void) | undefined)?.(
      type === "multiple" ? next : (next[0] ?? ""),
    );
  }

  const context: AccordionContextValue = {
    open,
    toggle,
    isLocked: (item) => type === "single" && !collapsible && open.includes(item),
    reduceMotion,
  };

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;

    const root = event.currentTarget;
    const triggers = Array.from(
      root.querySelectorAll<HTMLButtonElement>("[data-accordion-trigger]:not(:disabled)"),
    ).filter((trigger) => trigger.closest("[data-accordion]") === root);
    const index = triggers.indexOf(event.target as HTMLButtonElement);
    if (index === -1) return;

    const last = triggers.length - 1;
    const next = (
      {
        ArrowDown: index === last ? 0 : index + 1,
        ArrowUp: index === 0 ? last : index - 1,
        Home: 0,
        End: last,
      } as Record<string, number>
    )[event.key];
    if (next === undefined) return;

    event.preventDefault();
    triggers[next].focus();
  }

  return (
    <AccordionContext.Provider value={context}>
      <div
        ref={ref}
        data-accordion=""
        onKeyDown={handleKeyDown}
        style={neonVars(color, style)}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", "w-full", className)}
        {...rest}
      />
    </AccordionContext.Provider>
  );
});
Accordion.displayName = "Accordion";

export interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
  value: string;
  disabled?: boolean;
}

const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
  ({ className, value, disabled = false, children, ...props }, ref) => {
    const accordion = useAccordion("AccordionItem");
    const id = React.useId();
    const open = accordion.open.includes(value);
    const item = React.useMemo(
      () => ({ value, open, disabled, triggerId: `${id}-trigger`, contentId: `${id}-content` }),
      [value, open, disabled, id],
    );

    return (
      <AccordionItemContext.Provider value={item}>
        <div
          ref={ref}
          data-state={open ? "open" : "closed"}
          data-disabled={disabled ? "" : undefined}
          className={cn("relative border-b border-white/10 last:border-b-0", className)}
          {...props}
        >
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute bottom-4 left-0 top-4 w-px rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon)/0.8)]",
              "transition-opacity duration-200 ease-out motion-reduce:transition-none",
              open ? "opacity-100" : "opacity-0",
            )}
          />
          {children}
        </div>
      </AccordionItemContext.Provider>
    );
  },
);
AccordionItem.displayName = "AccordionItem";

const AccordionTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
  ({ className, onClick, children, ...props }, ref) => {
    const accordion = useAccordion("AccordionTrigger");
    const item = useAccordionItem("AccordionTrigger");
    const locked = accordion.isLocked(item.value);

    return (
      <h3 className="flex">
        <button
          ref={ref}
          type="button"
          id={item.triggerId}
          aria-expanded={item.open}
          aria-controls={item.contentId}
          aria-disabled={locked || undefined}
          disabled={item.disabled}
          data-accordion-trigger=""
          data-state={item.open ? "open" : "closed"}
          onClick={(event) => {
            onClick?.(event);
            if (!event.defaultPrevented) accordion.toggle(item.value);
          }}
          className={cn(
            "group/trigger flex flex-1 cursor-pointer items-center justify-between gap-4 rounded-md py-4 pl-4 text-left text-sm font-medium text-white/90 outline-none",
            "transition-colors duration-150 ease-out hover:text-white",
            "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
            "disabled:cursor-not-allowed disabled:opacity-40",
            locked && "cursor-default",
            className,
          )}
          {...props}
        >
          {children}
          <ChevronDown
            aria-hidden
            className={cn(
              "size-4 shrink-0 transition-[transform,color] duration-[220ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
              item.open ? "rotate-180 text-white/70" : "text-white/40 group-hover/trigger:text-white/70",
            )}
          />
        </button>
      </h3>
    );
  },
);
AccordionTrigger.displayName = "AccordionTrigger";

const AccordionContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => {
    const { reduceMotion } = useAccordion("AccordionContent");
    const item = useAccordionItem("AccordionContent");

    return (
      <AnimatePresence initial={false}>
        {item.open && (
          <motion.div
            key="content"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.23, 1, 0.32, 1] }}
            className="overflow-hidden"
          >
            <div
              ref={ref}
              role="region"
              id={item.contentId}
              aria-labelledby={item.triggerId}
              className={cn("pb-4 pl-4 pr-8 text-sm leading-relaxed text-white/55", className)}
              {...props}
            />
          </motion.div>
        )}
      </AnimatePresence>
    );
  },
);
AccordionContent.displayName = "AccordionContent";

export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
```
