shadimage
Iconiq
uiaccordionsource: ok · refreshed

Accordion

Accordion with spring height, staggered text, default or quiet disclosure styles, and optional multi-open behavior. Built with Motion.

npx shadcn add "https://iconiqui.com/r/accordion.json"
free

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@radix-ui/react-accordion
animationmotion

Files1 · source cached 10/2/2026, 1:59:00 AM

accordion.tsx
"use client";

import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

const componentThemeClassName =
  "[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]";

export interface AccordionItem {
  id: string;
  title: string;
  content: React.ReactNode;
}

export type AccordionVariant = "default" | "quiet";

export interface AccordionProps {
  items: AccordionItem[];
  className?: string;
  multiple?: boolean;
  variant?: AccordionVariant;
}

const contentShellTransition: Transition = {
  height: {
    type: "spring",
    stiffness: 138,
    damping: 27,
    mass: 0.98,
  },
  opacity: { duration: 0.26, ease: [0.18, 1, 0.32, 1] },
};

const contentMaskTransition: Transition = {
  duration: 0.38,
  ease: [0.16, 1, 0.3, 1],
};

const contentCopyTransition: Transition = {
  y: {
    type: "spring",
    stiffness: 146,
    damping: 23,
    mass: 0.98,
  },
  scale: {
    duration: 0.32,
    ease: [0.18, 1, 0.32, 1],
  },
  opacity: {
    duration: 0.22,
    ease: [0.18, 1, 0.32, 1],
    delay: 0.05,
  },
  filter: {
    duration: 0.28,
    ease: [0.18, 1, 0.32, 1],
    delay: 0.05,
  },
};

type AccordionRowProps = {
  item: AccordionItem;
  index: number;
  isOpen: boolean;
};

function getRowClassName(index: number) {
  return cn("group", index !== 0 && "border-border/80 border-t");
}

function getTriggerClassName(isOpen: boolean) {
  return isOpen ? "px-1 pt-5 pb-3" : "px-1 py-5";
}

function getContentWrapClassName() {
  return "px-1 pr-12 pb-5";
}

function getContentMaskClassName() {
  return "overflow-hidden";
}

function getContentCopyClassName() {
  return "space-y-3 text-sm leading-relaxed text-muted-foreground will-change-transform [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_li+li]:mt-1.5 [&_ol]:mt-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-3 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-5";
}

function getQuietContentClassName() {
  return "max-w-2xl pl-7 text-sm leading-relaxed text-muted-foreground [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_li+li]:mt-1.5 [&_ol]:mt-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-3 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-5";
}

function getQuietContentWrapClassName() {
  return "pt-1.5";
}

function getQuietContentMaskClassName() {
  return "overflow-hidden";
}

function AccordionContent({
  contentCopy,
  isOpen,
  maskClassName,
  wrapClassName,
}: {
  contentCopy: React.ReactNode;
  isOpen: boolean;
  maskClassName: string;
  wrapClassName: string;
}) {
  return (
    <AnimatePresence initial={false}>
      {isOpen ? (
        <AccordionPrimitive.Content asChild forceMount>
          <motion.div
            animate={{
              height: "auto",
              opacity: 1,
              clipPath: "inset(0% 0% 0% 0%)",
            }}
            className="overflow-hidden"
            exit={{
              height: 0,
              opacity: 0,
              clipPath: "inset(0% 0% 100% 0%)",
            }}
            initial={{
              height: 0,
              opacity: 0,
              clipPath: "inset(0% 0% 100% 0%)",
            }}
            transition={contentShellTransition}
          >
            <div className={wrapClassName}>
              <motion.div
                animate={{
                  clipPath: "inset(0% 0% 0% 0%)",
                  opacity: 1,
                }}
                className={maskClassName}
                exit={{
                  clipPath: "inset(0% 100% 0% 0%)",
                  opacity: 0.68,
                }}
                initial={{
                  clipPath: "inset(0% 100% 0% 0%)",
                  opacity: 0.68,
                }}
                transition={contentMaskTransition}
              >
                {contentCopy}
              </motion.div>
            </div>
          </motion.div>
        </AccordionPrimitive.Content>
      ) : null}
    </AnimatePresence>
  );
}

function AccordionTriggerLabel({ title }: { title: string }) {
  return (
    <motion.span
      animate={{ x: 0 }}
      className="pr-4 font-medium text-[15px] text-foreground leading-6 tracking-[-0.02em] sm:text-base"
      transition={{ type: "spring", stiffness: 300, damping: 28 }}
    >
      {title}
    </motion.span>
  );
}

function AccordionTriggerIndicator({ isOpen }: { isOpen: boolean }) {
  return (
    <motion.div
      animate={{ opacity: isOpen ? 1 : 0.72 }}
      aria-hidden
      className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center text-foreground"
      transition={{ type: "spring", stiffness: 360, damping: 24 }}
    >
      <span className="relative flex h-3 w-3 items-center justify-center">
        <span className="absolute h-px w-3 rounded-full bg-current" />
        <motion.span
          animate={{
            opacity: isOpen ? 0 : 1,
            scaleY: isOpen ? 0 : 1,
          }}
          className="absolute h-3 w-px origin-center rounded-full bg-current"
          transition={{
            duration: 0.18,
            ease: [0.22, 1, 0.36, 1],
          }}
        />
      </span>
    </motion.div>
  );
}

function AccordionRow({ item, index, isOpen }: AccordionRowProps) {
  const contentCopy = (
    <motion.div
      animate={{
        opacity: 1,
        y: 0,
        scale: 1,
        filter: "blur(0px)",
      }}
      className={getContentCopyClassName()}
      exit={{
        opacity: 0,
        y: -2,
        scale: 0.996,
        filter: "blur(1.5px)",
      }}
      initial={{
        opacity: 0,
        y: 7,
        scale: 0.998,
        filter: "blur(3px)",
      }}
      transition={contentCopyTransition}
    >
      {item.content}
    </motion.div>
  );

  return (
    <AccordionPrimitive.Item className={getRowClassName(index)} value={item.id}>
      <motion.div
        animate={{ opacity: 1, y: 0 }}
        initial={{ opacity: 0, y: 12 }}
        transition={{
          delay: index * 0.05,
          duration: 0.3,
          ease: [0.22, 1, 0.36, 1],
        }}
      >
        <AccordionPrimitive.Header className="flex">
          <AccordionPrimitive.Trigger
            className={cn(
              "flex w-full cursor-pointer items-start justify-between gap-6 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
              getTriggerClassName(isOpen)
            )}
          >
            <AccordionTriggerLabel title={item.title} />
            <AccordionTriggerIndicator isOpen={isOpen} />
          </AccordionPrimitive.Trigger>
        </AccordionPrimitive.Header>

        <AccordionContent
          contentCopy={contentCopy}
          isOpen={isOpen}
          maskClassName={getContentMaskClassName()}
          wrapClassName={getContentWrapClassName()}
        />
      </motion.div>
    </AccordionPrimitive.Item>
  );
}

function AccordionQuietRow({
  index,
  item,
  isOpen,
}: {
  index: number;
  item: AccordionItem;
  isOpen: boolean;
}) {
  const contentCopy = (
    <motion.div
      animate={{
        opacity: 1,
        y: 0,
        scale: 1,
        filter: "blur(0px)",
      }}
      className={getQuietContentClassName()}
      exit={{
        opacity: 0,
        y: -2,
        scale: 0.996,
        filter: "blur(1.5px)",
      }}
      initial={{
        opacity: 0,
        y: 7,
        scale: 0.998,
        filter: "blur(3px)",
      }}
      transition={contentCopyTransition}
    >
      {item.content}
    </motion.div>
  );

  return (
    <AccordionPrimitive.Item className="py-3.5" value={item.id}>
      <motion.div
        animate={{ opacity: 1, y: 0 }}
        initial={{ opacity: 0, y: 12 }}
        transition={{
          delay: index * 0.05,
          duration: 0.3,
          ease: [0.22, 1, 0.36, 1],
        }}
      >
        <AccordionPrimitive.Header className="flex">
          <AccordionPrimitive.Trigger className="flex w-full cursor-pointer items-baseline gap-4 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset">
            <span
              aria-hidden
              className={cn(
                "text-[15px] leading-none transition-colors",
                isOpen ? "text-foreground" : "text-muted-foreground/60"
              )}
            >
              {isOpen ? "−" : "+"}
            </span>
            <span className="font-normal text-[15px] text-foreground leading-6 tracking-[-0.02em] sm:text-base">
              {item.title}
            </span>
          </AccordionPrimitive.Trigger>
        </AccordionPrimitive.Header>

        <AccordionContent
          contentCopy={contentCopy}
          isOpen={isOpen}
          maskClassName={getQuietContentMaskClassName()}
          wrapClassName={getQuietContentWrapClassName()}
        />
      </motion.div>
    </AccordionPrimitive.Item>
  );
}

export function Accordion({
  items,
  className,
  multiple = false,
  variant = "default",
}: AccordionProps) {
  const [openItems, setOpenItems] = React.useState<string[]>([]);
  const isQuiet = variant === "quiet";
  const rows = items.map((item, index) =>
    isQuiet ? (
      <AccordionQuietRow
        index={index}
        isOpen={openItems.includes(item.id)}
        item={item}
        key={item.id}
      />
    ) : (
      <AccordionRow
        index={index}
        isOpen={openItems.includes(item.id)}
        item={item}
        key={item.id}
      />
    )
  );

  if (multiple) {
    return (
      <AccordionPrimitive.Root
        className={cn(
          componentThemeClassName,
          "mx-auto w-full max-w-2xl",
          className
        )}
        onValueChange={setOpenItems}
        type="multiple"
        value={openItems}
      >
        {rows}
      </AccordionPrimitive.Root>
    );
  }

  return (
    <AccordionPrimitive.Root
      className={cn(
        componentThemeClassName,
        "mx-auto w-full max-w-2xl",
        className
      )}
      collapsible
      onValueChange={(value) => setOpenItems(value ? [value] : [])}
      type="single"
      value={openItems[0]}
    >
      {rows}
    </AccordionPrimitive.Root>
  );
}

More from Iconiq

Browse all components in this library →