# Accordion

Accessible headings that reveal collapsible content panels.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

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

const items = [
  {
    "answer": "Yes. Base UI provides heading and panel relationships, keyboard activation, and disabled states.",
    "question": "Is it accessible?",
    "value": "accessible"
  },
  {
    "answer": "Use the shadcn CLI to install editable source code into your project.",
    "question": "How do I install it?",
    "value": "installation"
  },
  {
    "answer": "Yes. Enable the multiple prop to keep more than one panel open.",
    "question": "Can I open multiple panels?",
    "value": "multiple"
  }
];

export const AccordionDemo = () => (
  <Accordion defaultValue={["accessible"]}>
    {items.map((item) => (
      <AccordionItem key={item.value} value={item.value}>
        <AccordionTrigger>{item.question}</AccordionTrigger>
        <AccordionContent>{item.answer}</AccordionContent>
      </AccordionItem>
    ))}
  </Accordion>
);
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

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

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react cn lucide-react motion
```

## Usage [#usage]

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

export function Demo() {
  return (
    <Accordion defaultValue={["item-1"]}>
      <AccordionItem value="item-1">
        <AccordionTrigger>Is it accessible?</AccordionTrigger>
        <AccordionContent>
          Yes. Base UI connects each heading to its content panel.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="item-2">
        <AccordionTrigger>Can I customize it?</AccordionTrigger>
        <AccordionContent>
          Yes. The registry installs editable source into your project.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  );
}
```

Use your project's configured UI alias if it differs from `@/components/ui`.

## Examples [#examples]

<ComponentPreview name="accordion-demo" title="accordion-demo.tsx">
  <AccordionDemo />
</ComponentPreview>

### Multiple Panels [#multiple-panels]

Set `multiple` on `Accordion` to allow more than one panel to remain open. By default, opening an item closes the previously expanded item. Pressing an expanded trigger closes its panel.

### Disabled Items [#disabled-items]

Set `disabled` on `Accordion` to prevent interaction with all items, or on an individual `AccordionItem` to disable only that item.

### Controlled Values [#controlled-values]

Use `value` and `onValueChange` for controlled state. Both `value` and `defaultValue` are arrays of item values, even when only one panel can be open. Give each item a unique `value`.

### Keeping Content Mounted [#keeping-content-mounted]

Set `keepMounted` on `AccordionContent` to preserve its DOM when closed. Base UI hides closed panels from interaction.

### Custom Indicators [#custom-indicators]

Set `icon` on `AccordionTrigger` to any decorative React node. A single icon rotates by `iconRotation` degrees when expanded (default `180`). Set `iconRotation={45}` to turn a Plus into a close mark, or `0` to keep it still. The animation wrapper owns Motion behavior, so your icon component does not need Motion props or ref forwarding.

Supply `expandedIcon` for a different open-state indicator. Both indicators crossfade with a subtle scale transition; rotation is ignored in this mode. Pass `icon={null}` to hide the indicator entirely. Indicator content is decorative and hidden from assistive technology; keep the trigger text descriptive and never put interactive elements inside an icon.

<ComponentPreview name="accordion-icons-demo" title="accordion-icons-demo.tsx">
  <AccordionIconsDemo />
</ComponentPreview>

```tsx
<AccordionTrigger icon={<PlusIcon />} expandedIcon={<MinusIcon />}>
  Technical details
</AccordionTrigger>
```

Import the example icons from `lucide-react`. These customizations work with the same controlled state, keyboard activation, disabled behavior, and reduced-motion support as the default Chevron.

### Consumer Setup and Optional Stories [#consumer-setup-and-optional-stories]

Requires React 19, Tailwind CSS 4 with shadcn theme tokens, `@base-ui/react`, `cn`, `lucide-react`, and `motion`. The CLI follows your configured UI alias; review overwrite prompts before replacing a customized Accordion. The component has its own client boundary and may be imported by a Server Component; interactive callbacks must originate in a client module.

Install Accordion first, then optionally install portable stories:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/accordion-stories.json
```

Use your existing `@storybook/react` setup with matching Storybook versions (types and composition checked with 10.6.1), discover `**/*.stories.tsx` in your configured UI directory, and load your global Tailwind/theme stylesheet. The stories use the component's declared icon dependency, need no additional addons or providers, and do not overwrite the component or Storybook configuration. This project has no Storybook server; Storybook UI verification is not performed.

## Accessibility & Transitions [#accessibility--transitions]

* Triggers are native buttons inside level-three headings, with expanded state and panel relationships managed by Base UI.
* Tab moves between triggers. Enter or Space toggles the focused item.
* Visible focus rings and disabled states use the existing theme tokens.
* Panel height, content opacity, and indicator rotation or crossfade animate together with `motion/react` over `240ms`, using an ease-out curve without bounce or delayed content. Initially expanded panels render open without a first-paint collapse.
* Closing content remains mounted until the animation finishes, but becomes inert and hidden from assistive technology immediately. Rapid toggles reverse from the current animated position.
* Reduced-motion preferences make transitions immediate. Triggers do not scale.
* `className` on `AccordionContent` styles the inner content wrapper, following the shadcn composition.

See the [Base UI API reference](https://base-ui.com/react/components/accordion) for the complete primitive props, including `render` composition.

## Props [#props]

| Prop          | Type                     | Default          | Description                                                                                                                                                           |
| ------------- | ------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| defaultValue  | Value\[]                 | Not set          | Accordion: initial uncontrolled array of expanded item values.                                                                                                        |
| disabled      | boolean                  | false            | Accordion or AccordionItem: prevents interaction.                                                                                                                     |
| expandedIcon  | ReactNode                | Not set          | AccordionTrigger: optional expanded indicator. When provided, the two icons crossfade and gently scale instead of rotating.                                           |
| icon          | ReactNode                | ChevronDownIcon  | AccordionTrigger: decorative indicator React node. Pass null to hide it; custom icon components do not need Motion props or refs.                                     |
| iconRotation  | number                   | 180              | AccordionTrigger: expanded rotation in degrees for a single icon. Ignored when expandedIcon is supplied. Use 45 to turn Plus into a close mark, or 0 for no rotation. |
| iconStyle     | string (demo only)       | "chevron" (demo) | Demo composition: default chevron, Plus/Minus crossfade, Plus rotation, or no indicator. Configures AccordionTrigger props.                                           |
| keepMounted   | boolean                  | false            | AccordionContent: keeps closed panels in the DOM.                                                                                                                     |
| multiple      | boolean                  | false            | Accordion: allows multiple panels to remain open.                                                                                                                     |
| onValueChange | (value, details) => void | Not set          | Accordion: receives expanded values and Base UI event details.                                                                                                        |
| value         | Value\[]                 | Not set          | Accordion: controlled array of expanded item values.                                                                                                                  |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { ChevronDownIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";

const panelContext = createContext({
  hiddenUntilFound: false,
  keepMounted: false,
});
const transition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };

const PanelElement = ({
  nativeProps,
  render,
  state,
  ref,
  ...props
}: ComponentProps<"div"> & {
  nativeProps: ComponentProps<"div">;
  render?: AccordionPrimitive.Panel.Props["render"];
  state: AccordionPrimitive.Panel.State;
}) =>
  useRender({
    props: mergeProps(nativeProps, props),
    ref: [nativeProps.ref ?? null, ref ?? null],
    render,
    state: { ...state },
  });
const MotionPanelElement = motion.create(PanelElement);

const TriggerElement = ({
  nativeProps,
  state,
  render,
  children,
}: {
  nativeProps: ComponentProps<"button">;
  state: AccordionPrimitive.Trigger.State;
  render?: AccordionPrimitive.Trigger.Props["render"];
  children: ComponentProps<"button">["children"];
}) =>
  useRender({
    defaultTagName: "button",
    props: { ...nativeProps, children },
    ref: nativeProps.ref ?? null,
    render,
    state: { ...state },
  });

const AnimatedPanel = ({
  nativeProps,
  render,
  state,
  keepMounted,
  hiddenUntilFound,
}: {
  nativeProps: ComponentProps<"div">;
  render?: AccordionPrimitive.Panel.Props["render"];
  state: AccordionPrimitive.Panel.State;
  keepMounted: boolean;
  hiddenUntilFound: boolean;
}) => {
  const reduceMotion = useReducedMotion();
  const [retained, setRetained] = useState(state.open);
  const openRef = useRef(state.open);
  useEffect(() => {
    openRef.current = state.open;
    if (state.open) {
      setRetained(true);
    }
  }, [state.open]);
  const visible = state.open || retained;
  return (
    <MotionPanelElement
      nativeProps={{
        ...nativeProps,
        hidden: visible ? false : nativeProps.hidden,
      }}
      render={render}
      state={state}
      initial={false}
      animate={{ height: state.open ? "auto" : 0, opacity: state.open ? 1 : 0 }}
      transition={{
        ...transition,
        duration: reduceMotion ? 0 : transition.duration,
      }}
      inert={!state.open}
      aria-hidden={state.open ? undefined : true}
      onAnimationComplete={() => {
        if (!openRef.current) {
          setRetained(false);
        }
      }}
    >
      {visible || keepMounted || hiddenUntilFound ? nativeProps.children : null}
    </MotionPanelElement>
  );
};

const Accordion = <Value,>({
  className,
  keepMounted = false,
  hiddenUntilFound = false,
  ...props
}: AccordionPrimitive.Root.Props<Value>) => (
  <panelContext.Provider value={{ hiddenUntilFound, keepMounted }}>
    <AccordionPrimitive.Root
      data-slot="accordion"
      className={cn("flex w-full flex-col", className)}
      {...props}
      keepMounted={keepMounted}
      hiddenUntilFound={hiddenUntilFound}
    />
  </panelContext.Provider>
);

const AccordionItem = ({
  className,
  ...props
}: AccordionPrimitive.Item.Props) => (
  <AccordionPrimitive.Item
    data-slot="accordion-item"
    className={cn("not-last:border-b", className)}
    {...props}
  />
);

type AccordionTriggerProps = AccordionPrimitive.Trigger.Props & {
  icon?: ReactNode;
  expandedIcon?: ReactNode;
  iconRotation?: number;
};

const AccordionTrigger = ({
  className,
  children,
  render,
  icon = <ChevronDownIcon />,
  expandedIcon,
  iconRotation = 180,
  ...props
}: AccordionTriggerProps) => {
  const reduceMotion = useReducedMotion();
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none hover:not-data-disabled:underline focus-visible:ring-[3px] focus-visible:ring-ring/50 data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
          className
        )}
        {...props}
        render={(nativeProps, state) => (
          <TriggerElement
            nativeProps={nativeProps}
            state={state}
            render={render}
          >
            {children}
            {icon !== null && icon !== false && (
              <motion.span
                aria-hidden="true"
                data-slot="accordion-trigger-icon"
                className="pointer-events-none relative mt-0.5 inline-grid size-4 shrink-0 place-items-center text-muted-foreground"
                initial={false}
                animate={{
                  rotate:
                    state.open && expandedIcon === undefined ? iconRotation : 0,
                }}
                transition={{
                  ...transition,
                  duration: reduceMotion ? 0 : transition.duration,
                }}
              >
                {expandedIcon === undefined ? (
                  icon
                ) : (
                  <>
                    <motion.span
                      className="col-start-1 row-start-1 inline-flex"
                      initial={false}
                      animate={{
                        opacity: state.open ? 0 : 1,
                        scale: state.open ? 0.85 : 1,
                      }}
                      transition={{
                        ...transition,
                        duration: reduceMotion ? 0 : transition.duration,
                      }}
                    >
                      {icon}
                    </motion.span>
                    <motion.span
                      className="col-start-1 row-start-1 inline-flex"
                      initial={false}
                      animate={{
                        opacity: state.open ? 1 : 0,
                        scale: state.open ? 1 : 0.85,
                      }}
                      transition={{
                        ...transition,
                        duration: reduceMotion ? 0 : transition.duration,
                      }}
                    >
                      {expandedIcon}
                    </motion.span>
                  </>
                )}
              </motion.span>
            )}
          </TriggerElement>
        )}
      />
    </AccordionPrimitive.Header>
  );
};

const AccordionContent = ({
  className,
  children,
  render,
  keepMounted,
  hiddenUntilFound,
  ...props
}: AccordionPrimitive.Panel.Props) => {
  const defaults = useContext(panelContext);
  return (
    <AccordionPrimitive.Panel
      data-slot="accordion-content"
      className="overflow-hidden text-sm"
      {...props}
      keepMounted
      hiddenUntilFound={hiddenUntilFound ?? defaults.hiddenUntilFound}
      render={(nativeProps, state) => (
        <AnimatedPanel
          nativeProps={nativeProps}
          state={state}
          render={render}
          keepMounted={keepMounted ?? defaults.keepMounted}
          hiddenUntilFound={hiddenUntilFound ?? defaults.hiddenUntilFound}
        />
      )}
    >
      <div
        className={cn(
          "pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-4 [&_p:not(:last-child)]:mb-4",
          className
        )}
      >
        {children}
      </div>
    </AccordionPrimitive.Panel>
  );
};

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
export type { AccordionTriggerProps };

```

## Credits [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/accordion): Base UI Accordion composition and foundation. Vandor UI adds Motion-powered height, fade, and chevron transitions with reduced-motion support and direct cn integration.
