For the complete documentation index, see 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.
1
Support

Accordion

Accessible headings that reveal collapsible content panels.

Playground

Live preview

Yes. Base UI provides heading and panel relationships, keyboard activation, and disabled states.

Click a heading to toggle its panel. Tab moves between triggers; Enter or Space toggles the focused item.
Customize Accordion
Customize
accordion-demo.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

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/accordion.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies4 required packages
  • @base-ui/react
  • cn
  • lucide-react
  • motion
$ pnpm add @base-ui/react cn lucide-react motion

Usage

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

Yes. Base UI provides heading and panel relationships, keyboard activation, and disabled states.

accordion-demo.tsx
"use client";

import { accordionItems } from "@/lib/accordion-playground";
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";

export const AccordionDemo = () => (
  <Accordion className="max-w-md" defaultValue={["accessible"]}>
    {accordionItems.map((item) => (
      <AccordionItem key={item.value} value={item.value}>
        <AccordionTrigger>{item.question}</AccordionTrigger>
        <AccordionContent>{item.answer}</AccordionContent>
      </AccordionItem>
    ))}
  </Accordion>
);

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

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

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

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

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.

accordion-icons-demo.tsx
"use client";

import { MinusIcon, PlusIcon } from "lucide-react";

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

export const AccordionIconsDemo = () => (
  <Accordion className="max-w-md">
    <AccordionItem value="swap">
      <AccordionTrigger icon={<PlusIcon />} expandedIcon={<MinusIcon />}>
        Technical details
      </AccordionTrigger>
      <AccordionContent>
        Reference: DEMO-1042. The Plus and Minus indicators crossfade while the
        panel expands.
      </AccordionContent>
    </AccordionItem>
    <AccordionItem value="rotate">
      <AccordionTrigger icon={<PlusIcon />} iconRotation={45}>
        A rotating custom icon
      </AccordionTrigger>
      <AccordionContent>
        A single Plus indicator rotates into a close mark. Use any decorative
        React node.
      </AccordionContent>
    </AccordionItem>
  </Accordion>
);
<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

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:

$ pnpm dlx 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

  • 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 for the complete primitive props, including render composition.

Props

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
defaultValueValue[]

Accordion: initial uncontrolled array of expanded item values.

Not set
disabledboolean

Accordion or AccordionItem: prevents interaction.

false
expandedIconReactNode

AccordionTrigger: optional expanded indicator. When provided, the two icons crossfade and gently scale instead of rotating.

Not set
iconReactNode

AccordionTrigger: decorative indicator React node. Pass null to hide it; custom icon components do not need Motion props or refs.

ChevronDownIcon
iconRotationnumber

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.

180
iconStylestring (demo only)

Demo composition: default chevron, Plus/Minus crossfade, Plus rotation, or no indicator. Configures AccordionTrigger props.

"chevron" (demo)
keepMountedboolean

AccordionContent: keeps closed panels in the DOM.

false
multipleboolean

Accordion: allows multiple panels to remain open.

false
onValueChange(value, details) => void

Accordion: receives expanded values and Base UI event details.

Not set
valueValue[]

Accordion: controlled array of expanded item values.

Not set

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

"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

  • shadcn/ui: Base UI Accordion composition and foundation. Vandor UI adds Motion-powered height, fade, and chevron transitions with reduced-motion support and direct cn integration.