shadimage
soralabs
uibase-accordionsource: ok · hit

Base Accordion

An animated accordion component built on Base UI with spring physics, height collapse mask gradient, blur transitions, and gliding layoutId focus ring.

npx shadcn add "https://ui.soralabs.studio/r/base-accordion.json"
free

Visual style

glass-blurgradient

Interaction

accordion

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
ui-primitives@base-ui/react
animationmotion
iconslucide-react

Registry dependencies

utils@soralabs/hooks-use-controlled-state

Files1 · source cached 10/8/2026, 3:40:42 PM

registry/ui/base/accordion/index.tsx
"use client";

import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { useControlledState } from "@/hooks/use-controlled-state";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  type Transition,
  useReducedMotion,
} from "motion/react";
import {
  type ComponentProps,
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useId,
  useMemo,
  useState,
} from "react";

type AccordionItemValue = string | number;

interface AccordionContextValue {
  disableAnimation: boolean;
  disabled: boolean;
  focusRingClassName?: string;
  focusRingLayoutId: string;
  multiple: boolean;
  openValues: AccordionItemValue[];
  setOpenValues: (values: AccordionItemValue[]) => void;
  showFocusRing: boolean;
  transition: Transition;
  variant?: "default" | "bordered" | "separated" | "ghost";
}

const AccordionContext = createContext<AccordionContextValue | null>(null);

function useAccordionContext() {
  const context = useContext(AccordionContext);
  if (!context) {
    throw new Error(
      "Accordion compound components must be used within an <Accordion>."
    );
  }
  return context;
}

interface AccordionItemContextValue {
  disabled: boolean;
  isFocused: boolean;
  isOpen: boolean;
  setIsFocused: (isFocused: boolean) => void;
  value: AccordionItemValue;
}

const AccordionItemContext = createContext<AccordionItemContextValue | null>(
  null
);

function useAccordionItemContext() {
  const context = useContext(AccordionItemContext);
  if (!context) {
    throw new Error(
      "AccordionItem subcomponents must be used within an <AccordionItem>."
    );
  }
  return context;
}

const accordionVariants = cva("flex w-full flex-col", {
  variants: {
    variant: {
      default: "",
      bordered: "rounded-xl border border-border bg-card/40 p-2 shadow-xs",
      separated: "gap-2.5",
      ghost: "",
    },
  },
  defaultVariants: {
    variant: "default",
  },
});

const accordionItemVariants = cva("relative transition-colors", {
  variants: {
    variant: {
      default: "border-border border-b not-last:border-b",
      bordered:
        "border-border/70 border-b not-last:border-b px-3 last:border-b-0",
      separated: "rounded-xl border border-border bg-card/60 px-4 shadow-xs",
      ghost: "rounded-lg px-3 hover:bg-muted/50",
    },
  },
  defaultVariants: {
    variant: "default",
  },
});

const accordionTriggerVariants = cva(
  "group/accordion-trigger relative flex flex-1 select-none items-center justify-between py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "",
        bordered: "py-3.5",
        separated: "py-3.5",
        ghost: "py-3",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

const DEFAULT_SPRING_TRANSITION: Transition = {
  type: "spring",
  bounce: 0.2,
  visualDuration: 0.4,
};

function normalizeValues(
  val?: AccordionItemValue | AccordionItemValue[]
): AccordionItemValue[] | undefined {
  if (val === undefined) {
    return;
  }
  if (Array.isArray(val)) {
    return val;
  }
  return [val];
}

interface AccordionProps
  extends Omit<
      ComponentProps<typeof AccordionPrimitive.Root>,
      "value" | "defaultValue" | "onValueChange"
    >,
    VariantProps<typeof accordionVariants> {
  /**
   * The initial expanded value(s) for uncontrolled mode.
   */
  defaultValue?: AccordionItemValue | AccordionItemValue[];
  /**
   * Whether to disable all motion physics and animations.
   * @default false
   */
  disableAnimation?: boolean;
  /**
   * Additional CSS classes for the animated focus ring.
   */
  focusRingClassName?: string;
  /**
   * Custom layoutId for the focus ring shared layout animation.
   */
  focusRingLayoutId?: string;
  /**
   * Event handler called when an accordion item expands or collapses.
   */
  onValueChange?: (
    value: AccordionItemValue[],
    eventDetails?: AccordionPrimitive.Root.ChangeEventDetails
  ) => void;
  /**
   * Whether to render the gliding animated focus ring on keyboard focus.
   * @default true
   */
  showFocusRing?: boolean;
  /**
   * Spring transition physics configuration for layout animations.
   * @default { type: "spring", bounce: 0.2, visualDuration: 0.4 }
   */
  transition?: Transition;
  /**
   * The controlled value of the item(s) that should be expanded.
   */
  value?: AccordionItemValue | AccordionItemValue[];
}

/**
 * Animated Accordion component built on Base UI with Motion spring physics,
 * blur/mask transitions, gliding layoutId focus ring, and full shadcn/ui customizability.
 */
function Accordion({
  className,
  variant = "default",
  value: controlledValueProp,
  defaultValue: defaultValueProp,
  onValueChange,
  multiple = false,
  disabled = false,
  disableAnimation = false,
  transition = DEFAULT_SPRING_TRANSITION,
  showFocusRing = true,
  focusRingClassName,
  focusRingLayoutId: customFocusRingLayoutId,
  children,
  ...props
}: AccordionProps) {
  const generatedLayoutId = useId();
  const focusRingLayoutId =
    customFocusRingLayoutId ?? `accordion-focus-${generatedLayoutId}`;

  const normalizedDefaultValue = useMemo(
    () => normalizeValues(defaultValueProp) ?? [],
    [defaultValueProp]
  );
  const normalizedControlledValue = useMemo(
    () => normalizeValues(controlledValueProp),
    [controlledValueProp]
  );

  const [openValues, setOpenValues] = useControlledState<AccordionItemValue[]>({
    defaultValue: normalizedDefaultValue,
    onChange: (nextValues) => {
      onValueChange?.(
        nextValues,
        undefined as unknown as AccordionPrimitive.Root.ChangeEventDetails
      );
    },
    value: normalizedControlledValue,
  });

  const handleBaseUiValueChange = useCallback(
    (
      nextValues: AccordionItemValue[],
      eventDetails: AccordionPrimitive.Root.ChangeEventDetails
    ) => {
      setOpenValues(nextValues);
      onValueChange?.(nextValues, eventDetails);
    },
    [onValueChange, setOpenValues]
  );

  const contextValue = useMemo(
    () => ({
      openValues,
      setOpenValues,
      multiple: Boolean(multiple),
      disabled: Boolean(disabled),
      disableAnimation: Boolean(disableAnimation),
      transition,
      showFocusRing: Boolean(showFocusRing),
      focusRingClassName,
      focusRingLayoutId,
      variant: variant ?? "default",
    }),
    [
      openValues,
      setOpenValues,
      multiple,
      disabled,
      disableAnimation,
      transition,
      showFocusRing,
      focusRingClassName,
      focusRingLayoutId,
      variant,
    ]
  );

  return (
    <AccordionContext.Provider value={contextValue}>
      <MotionConfig
        transition={disableAnimation ? { duration: 0 } : transition}
      >
        <AccordionPrimitive.Root
          className={cn(accordionVariants({ variant, className }))}
          data-slot="accordion"
          disabled={disabled}
          keepMounted
          multiple={multiple}
          onValueChange={handleBaseUiValueChange}
          value={openValues}
          {...props}
        >
          {children}
        </AccordionPrimitive.Root>
      </MotionConfig>
    </AccordionContext.Provider>
  );
}

interface AccordionItemProps
  extends ComponentProps<typeof AccordionPrimitive.Item> {
  /**
   * Additional CSS classes for the item container.
   */
  className?: string;
  /**
   * Unique value identifying this item.
   */
  value: AccordionItemValue;
}

/**
 * Individual expandable item within an Accordion.
 */
function AccordionItem({
  className,
  value,
  disabled = false,
  children,
  ...props
}: AccordionItemProps) {
  const { openValues, disabled: rootDisabled, variant } = useAccordionContext();
  const [isFocused, setIsFocused] = useState(false);
  const isOpen = openValues.includes(value);
  const isItemDisabled = disabled || rootDisabled;

  const itemContextValue = useMemo(
    () => ({
      value,
      isOpen,
      isFocused,
      setIsFocused,
      disabled: isItemDisabled,
    }),
    [value, isOpen, isFocused, isItemDisabled]
  );

  return (
    <AccordionItemContext.Provider value={itemContextValue}>
      <AccordionPrimitive.Item
        className={cn(accordionItemVariants({ variant, className }))}
        data-slot="accordion-item"
        data-state={isOpen ? "open" : "closed"}
        disabled={isItemDisabled}
        value={value}
        {...props}
      >
        {children}
      </AccordionPrimitive.Item>
    </AccordionItemContext.Provider>
  );
}

type AccordionHeaderProps = ComponentProps<typeof AccordionPrimitive.Header>;

/**
 * Accessible header container wrapping the accordion trigger button.
 */
function AccordionHeader({
  className,
  children,
  ...props
}: AccordionHeaderProps) {
  return (
    <AccordionPrimitive.Header
      className={cn("flex", className)}
      data-slot="accordion-header"
      {...props}
    >
      {children}
    </AccordionPrimitive.Header>
  );
}

interface AccordionTriggerProps
  extends ComponentProps<typeof AccordionPrimitive.Trigger> {
  /**
   * Custom chevron or trailing icon element.
   */
  chevron?: ReactNode;
  /**
   * Additional CSS class name for the chevron icon.
   */
  chevronClassName?: string;
  /**
   * Additional CSS class name for the animated focus ring.
   */
  focusRingClassName?: string;
  /**
   * Whether to hide the rotating chevron indicator.
   * @default false
   */
  hideChevron?: boolean;
  /**
   * Whether to display the focus ring on this trigger.
   * Inherits from root Accordion if not specified.
   */
  showFocusRing?: boolean;
}

/**
 * Interactive button that expands or collapses the accordion panel.
 * Features Motion gliding focus ring, pressed spring tap, and smooth chevron rotation.
 */
function AccordionTrigger({
  className,
  children,
  chevron,
  chevronClassName,
  hideChevron = false,
  focusRingClassName: triggerFocusRingClassName,
  showFocusRing: triggerShowFocusRing,
  render,
  onFocus,
  onBlur,
  ...props
}: AccordionTriggerProps) {
  const {
    showFocusRing: rootShowFocusRing,
    focusRingClassName: rootFocusRingClassName,
    focusRingLayoutId,
    disableAnimation,
    transition,
    variant,
  } = useAccordionContext();
  const { isOpen, isFocused, setIsFocused, disabled } =
    useAccordionItemContext();
  const prefersReducedMotion = useReducedMotion();
  const shouldAnimate = !(disableAnimation || prefersReducedMotion);

  const displayFocusRing = triggerShowFocusRing ?? rootShowFocusRing;
  const mergedFocusRingClassName =
    triggerFocusRingClassName ?? rootFocusRingClassName;

  const handleFocus = useCallback(
    (
      event: Parameters<
        NonNullable<
          ComponentProps<typeof AccordionPrimitive.Trigger>["onFocus"]
        >
      >[0]
    ) => {
      onFocus?.(event);
      if (event.target.matches(":focus-visible")) {
        setIsFocused(true);
      }
    },
    [onFocus, setIsFocused]
  );

  const handleBlur = useCallback(
    (
      event: Parameters<
        NonNullable<ComponentProps<typeof AccordionPrimitive.Trigger>["onBlur"]>
      >[0]
    ) => {
      onBlur?.(event);
      setIsFocused(false);
    },
    [onBlur, setIsFocused]
  );

  return (
    <AccordionHeader>
      <AccordionPrimitive.Trigger
        className={cn(accordionTriggerVariants({ variant, className }))}
        data-slot="accordion-trigger"
        data-state={isOpen ? "open" : "closed"}
        disabled={disabled}
        onBlur={handleBlur}
        onFocus={handleFocus}
        render={
          render ?? (
            <motion.button
              type="button"
              variants={
                shouldAnimate ? { pressed: { scale: 0.99 } } : undefined
              }
              whileTap={shouldAnimate ? "pressed" : undefined}
            />
          )
        }
        {...props}
      >
        <span className="relative z-10">{children}</span>

        {!hideChevron && (
          <span className="relative z-10 flex shrink-0 items-center">
            {chevron ?? (
              <motion.div
                animate={{ rotate: isOpen ? 180 : 0 }}
                className="pointer-events-none shrink-0"
                style={{ willChange: "transform" }}
                transition={shouldAnimate ? transition : { duration: 0 }}
              >
                <ChevronDownIcon
                  className={cn(
                    "size-4 text-muted-foreground transition-colors group-hover/accordion-trigger:text-foreground",
                    chevronClassName
                  )}
                  data-slot="accordion-trigger-icon"
                />
              </motion.div>
            )}
          </span>
        )}

        {displayFocusRing && isFocused && shouldAnimate && (
          <motion.div
            className={cn(
              "pointer-events-none absolute -inset-1 z-0 rounded-lg bg-accent/60 ring-2 ring-ring/50",
              mergedFocusRingClassName
            )}
            data-slot="accordion-focus-ring"
            layoutId={focusRingLayoutId}
            transition={{ type: "spring", visualDuration: 0.2, bounce: 0.2 }}
            variants={{ pressed: { scale: 0.98 } }}
          />
        )}
      </AccordionPrimitive.Trigger>
    </AccordionHeader>
  );
}

const contentVariants = {
  open: {
    height: "auto",
    maskImage: "linear-gradient(to bottom, black 100%, transparent 100%)",
  },
  closed: {
    height: 0,
    maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
  },
};

const innerVariants = {
  open: {
    filter: "blur(0px)",
    opacity: 1,
  },
  closed: {
    filter: "blur(2px)",
    opacity: 0,
  },
};

const staticContentVariants = {
  open: { height: "auto" },
  closed: { height: 0 },
};

const staticInnerVariants = {
  open: { opacity: 1, filter: "none" },
  closed: { opacity: 0, filter: "none" },
};

interface AccordionContentProps
  extends ComponentProps<typeof AccordionPrimitive.Panel> {
  /**
   * Additional CSS classes for the inner wrapper element.
   */
  innerClassName?: string;
}

/**
 * Collapsible panel containing the accordion content.
 * Features Motion height collapse, gradient mask fading, and blur transitions.
 */
function AccordionContent({
  className,
  innerClassName,
  children,
  ...props
}: AccordionContentProps) {
  const { disableAnimation, transition } = useAccordionContext();
  const { isOpen } = useAccordionItemContext();
  const prefersReducedMotion = useReducedMotion();
  const shouldAnimate = !(disableAnimation || prefersReducedMotion);

  return (
    <AccordionPrimitive.Panel
      data-slot="accordion-content"
      data-state={isOpen ? "open" : "closed"}
      hiddenUntilFound={false}
      keepMounted
      render={(panelProps) => {
        const { hidden: _hidden, ...restProps } = panelProps;
        return (
          <div
            className={cn("overflow-hidden text-sm", className)}
            {...restProps}
          />
        );
      }}
      {...props}
    >
      <AnimatePresence initial={false}>
        {isOpen && (
          <motion.div
            animate="open"
            className="overflow-hidden"
            exit="closed"
            initial="closed"
            key="accordion-panel-content"
            transition={shouldAnimate ? transition : { duration: 0 }}
            variants={shouldAnimate ? contentVariants : staticContentVariants}
          >
            <motion.div
              animate="open"
              exit="closed"
              initial="closed"
              transition={shouldAnimate ? transition : { duration: 0 }}
              variants={shouldAnimate ? innerVariants : staticInnerVariants}
            >
              <div
                className={cn(
                  "pt-0 pb-4 text-muted-foreground text-sm leading-relaxed [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
                  innerClassName
                )}
              >
                {children}
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </AccordionPrimitive.Panel>
  );
}

export type {
  AccordionContentProps,
  AccordionHeaderProps,
  AccordionItemProps,
  AccordionProps,
  AccordionTriggerProps,
};
export {
  Accordion,
  Accordion as BaseAccordion,
  Accordion as default,
  AccordionContent,
  AccordionHeader,
  AccordionItem,
  AccordionTrigger,
  accordionItemVariants,
  accordionTriggerVariants,
  accordionVariants,
  useAccordionContext,
  useAccordionItemContext,
};

More from soralabs

Browse all components in this library →