Accordion
Calm shadcn-compatible accordion built on Collapsible (Accordion, AccordionItem, AccordionTrigger, AccordionContent) with type single or multiple, collapsible, controlled or uncontrolled value, a spring-rotating chevron, height-morphing panels and Arrow/Home/End focus movement between triggers; the quiet counterpart to the springy Bouncy Accordion.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/accordion
import { ChevronDown } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
useCollapsible,
} from "@/components/motion/collapsible";
import { SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Accordion is the calm, shadcn-compatible stack of disclosures built from
// `Collapsible`. `BouncyAccordion` is the springy single-open variant with icon
// rows; `FaqSection` is a full marketing block. Use this one anywhere a plain
// accordion belongs (settings groups, FAQs, docs sidebars).
interface AccordionContextValue {
value: string[];
toggle: (item: string) => void;
type: "single" | "multiple";
collapsible: boolean;
disabled: boolean;
rootId: string;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion(component: string) {
const context = useContext(AccordionContext);
if (!context) throw new Error(`${component} must be used within <Accordion>.`);
return context;
}
interface AccordionBaseProps
extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue" | "dir"> {
disabled?: boolean;
children?: ReactNode;
}
export interface AccordionSingleProps extends AccordionBaseProps {
type: "single";
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Allow closing the open item so none is open. Default false (shadcn/Radix parity). */
collapsible?: boolean;
}
export interface AccordionMultipleProps extends AccordionBaseProps {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
collapsible?: never;
}
export type AccordionProps = AccordionSingleProps | AccordionMultipleProps;
const toArray = (v: string | string[] | undefined) =>
v === undefined ? undefined : Array.isArray(v) ? v : v ? [v] : [];
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(function Accordion(
props,
ref,
) {
const {
type,
value: valueProp,
defaultValue,
onValueChange,
collapsible = false,
disabled = false,
className,
children,
...rest
} = props as AccordionBaseProps & {
type: "single" | "multiple";
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: ((value: string) => void) | ((value: string[]) => void);
collapsible?: boolean;
};
const [internal, setInternal] = useState<string[]>(() => toArray(defaultValue) ?? []);
const controlled = valueProp !== undefined;
const value = controlled ? (toArray(valueProp) ?? []) : internal;
const rootId = useId();
const toggle = useCallback(
(item: string) => {
const isOpen = value.includes(item);
let next: string[];
if (type === "single") {
if (isOpen && !collapsible) return;
next = isOpen ? [] : [item];
} else {
next = isOpen ? value.filter((v) => v !== item) : [...value, item];
}
if (!controlled) setInternal(next);
if (type === "single") {
(onValueChange as ((v: string) => void) | undefined)?.(next[0] ?? "");
} else {
(onValueChange as ((v: string[]) => void) | undefined)?.(next);
}
},
[value, type, collapsible, controlled, onValueChange],
);
return (
<AccordionContext.Provider
value={{ value, toggle, type, collapsible, disabled, rootId }}
>
<div
ref={ref}
data-orientation="vertical"
data-accordion-root={rootId}
className={cn("w-full", className)}
{...rest}
>
{children}
</div>
</AccordionContext.Provider>
);
});
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
value: string;
disabled?: boolean;
children?: ReactNode;
}
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(
function AccordionItem({ value, disabled = false, className, children, ...rest }, ref) {
const accordion = useAccordion("AccordionItem");
const open = accordion.value.includes(value);
const itemDisabled = accordion.disabled || disabled;
return (
<Collapsible
ref={ref}
open={open}
onOpenChange={() => accordion.toggle(value)}
disabled={itemDisabled}
className={cn("border-b border-border", className)}
{...rest}
>
{children}
</Collapsible>
);
},
);
export interface AccordionTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Replace the rotating chevron, or pass `null` to hide it. */
icon?: ReactNode;
/** Heading element wrapping the trigger (WAI-ARIA accordion pattern). */
headingLevel?: 2 | 3 | 4 | 5 | 6;
children?: ReactNode;
}
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
function AccordionTrigger(
{ icon, headingLevel = 3, className, children, onKeyDown, ...rest },
ref,
) {
const accordion = useAccordion("AccordionTrigger");
const { open } = useCollapsible("AccordionTrigger");
const reduce = useReducedMotion();
// A single, non-collapsible accordion can't close its open item, so the
// trigger reports that instead of silently ignoring the press.
const locked = accordion.type === "single" && !accordion.collapsible && open;
const Heading = `h${headingLevel}` as const;
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const keys = ["ArrowDown", "ArrowUp", "Home", "End"];
if (!keys.includes(event.key)) return;
const root = event.currentTarget.closest<HTMLElement>(
`[data-accordion-root="${accordion.rootId}"]`,
);
const scope = root ?? document;
const triggers = Array.from(
scope.querySelectorAll<HTMLButtonElement>(
`[data-accordion-trigger="${accordion.rootId}"]`,
),
).filter((el) => !el.disabled);
const index = triggers.indexOf(event.currentTarget);
if (index < 0 || triggers.length === 0) return;
event.preventDefault();
const last = triggers.length - 1;
const next =
event.key === "Home"
? 0
: event.key === "End"
? last
: event.key === "ArrowDown"
? (index + 1) % triggers.length
: (index - 1 + triggers.length) % triggers.length;
triggers[next]?.focus();
};
return (
<Heading className="flex">
<CollapsibleTrigger
ref={ref}
data-accordion-trigger={accordion.rootId}
aria-disabled={locked || undefined}
className={cn(
"group/accordion-trigger flex flex-1 items-center justify-between gap-4 rounded-md py-4 text-left text-sm font-medium text-foreground outline-none transition-colors",
"hover:text-foreground/80 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onKeyDown={handleKeyDown}
{...rest}
>
{children}
{icon === null ? null : (
<motion.span
aria-hidden
className="grid size-4 shrink-0 place-items-center text-muted-foreground"
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
{icon ?? <ChevronDown className="size-4" />}
</motion.span>
)}
</CollapsibleTrigger>
</Heading>
);
},
);
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export function AccordionContent({ className, children, ...rest }: AccordionContentProps) {
useAccordion("AccordionContent");
return (
<CollapsibleContent
region
innerClassName={cn("pb-4 text-sm leading-6 text-muted-foreground", className)}
{...rest}
>
{children}
</CollapsibleContent>
);
}
"use client";
import { motion, useReducedMotion, type Transition } from "motion/react";
import {
cloneElement,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useId,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Collapsible is the unstyled disclosure primitive: one trigger, one panel.
// `Accordion` (accordion.tsx) is built from it; `BouncyAccordion` is the
// springy, single-open icon-row variant. Reach for this when a single region
// should show or hide in flow ("Show 3 more", an expandable activity row).
/** Entrance is a touch longer than the exit — exits should feel faster. */
const OPEN_TRANSITION: Transition = { duration: 0.28, ease: EASE_OUT };
const CLOSE_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };
interface CollapsibleContextValue {
open: boolean;
setOpen: (open: boolean) => void;
disabled: boolean;
triggerId: string;
contentId: string;
}
const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
export function useCollapsible(component = "useCollapsible") {
const context = useContext(CollapsibleContext);
if (!context) {
throw new Error(`${component} must be used within <Collapsible>.`);
}
return context;
}
export interface CollapsibleProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
/** Override the generated trigger id (the content is labelled by it). */
triggerId?: string;
/** Override the generated content id (the trigger controls it). */
contentId?: string;
children?: ReactNode;
}
export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
function Collapsible(
{
open: openProp,
defaultOpen = false,
onOpenChange,
disabled = false,
triggerId: triggerIdProp,
contentId: contentIdProp,
className,
children,
...rest
},
ref,
) {
const [internal, setInternal] = useState(defaultOpen);
const controlled = openProp !== undefined;
const open = controlled ? openProp : internal;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternal(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const baseId = useId();
const triggerId = triggerIdProp ?? `${baseId}-trigger`;
const contentId = contentIdProp ?? `${baseId}-content`;
return (
<CollapsibleContext.Provider
value={{ open, setOpen, disabled, triggerId, contentId }}
>
<div
ref={ref}
data-state={open ? "open" : "closed"}
data-disabled={disabled ? "" : undefined}
className={cn(className)}
{...rest}
>
{children}
</div>
</CollapsibleContext.Provider>
);
},
);
type ClonableChild = ReactElement<{
onClick?: (event: MouseEvent<HTMLElement>) => void;
[key: string]: unknown;
}>;
export interface CollapsibleTriggerProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Merge trigger behaviour onto the single child element instead of rendering a `<button>`. */
asChild?: boolean;
}
/** Toggles the nearest `Collapsible`. Unstyled `<button>` by default. */
export const CollapsibleTrigger = forwardRef<
HTMLButtonElement,
CollapsibleTriggerProps
>(function CollapsibleTrigger(
{ asChild, children, onClick, className, disabled: disabledProp, ...rest },
ref,
) {
const context = useCollapsible("CollapsibleTrigger");
const disabled = context.disabled || Boolean(disabledProp);
const shared = {
id: context.triggerId,
"aria-expanded": context.open,
"aria-controls": context.contentId,
"data-state": context.open ? "open" : "closed",
"data-disabled": disabled ? "" : undefined,
};
if (asChild && isValidElement(children)) {
const child = children as ClonableChild;
return cloneElement(child, {
...shared,
onClick: (event: MouseEvent<HTMLElement>) => {
child.props.onClick?.(event);
if (!event.defaultPrevented && !disabled) context.setOpen(!context.open);
},
});
}
return (
<button
ref={ref}
type="button"
disabled={disabled}
className={cn(className)}
{...rest}
{...shared}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.setOpen(!context.open);
}}
>
{children}
</button>
);
});
export interface CollapsibleContentProps extends HTMLAttributes<HTMLDivElement> {
/** Class for the inner wrapper that holds the children (padding goes here,
* never on the animated outer element, so the closed height is truly 0). */
innerClassName?: string;
/** Set to give the panel `role="region"` labelled by its trigger. */
region?: boolean;
children?: ReactNode;
}
/** The disclosed panel. Stays mounted (inert while closed) so `aria-controls` always resolves. */
export function CollapsibleContent({
className,
innerClassName,
region = false,
children,
style,
...rest
}: CollapsibleContentProps) {
const { open, triggerId, contentId } = useCollapsible("CollapsibleContent");
const reduce = useReducedMotion();
const fade = open ? OPEN_TRANSITION : CLOSE_TRANSITION;
return (
// Height is animated on purpose: disclosure is a layout job — the panel
// has to push the content below it down in flow, which transforms can't.
// Reduced motion snaps the height and keeps only the opacity fade.
<motion.div
{...(rest as Record<string, unknown>)}
id={contentId}
role={region ? "region" : undefined}
aria-labelledby={region ? triggerId : undefined}
data-state={open ? "open" : "closed"}
inert={!open}
initial={false}
animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }}
transition={
reduce ? { height: { duration: 0 }, opacity: { duration: 0.15 } } : fade
}
style={style}
className={cn("overflow-hidden", className)}
>
<div className={innerClassName}>{children}</div>
</motion.div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
AccordionContent
className?string—Accordion
type"multiple" | "single"—value?string | string[]—defaultValue?string | string[]—onValueChange?((value: string) => void) | ((value: string[]) => void)—collapsible?booleanAllow closing the open item so none is open. Default false (shadcn/Radix parity).
—disabled?booleanfalseclassName?string—AccordionItem
valuestring—disabled?booleanfalseclassName?string—AccordionTrigger
icon?ReactNodeReplace the rotating chevron, or pass `null` to hide it.
—headingLevel?2 | 3 | 4 | 5 | 6Heading element wrapping the trigger (WAI-ARIA accordion pattern).
3className?string—Updated