Skip to content

Bouncy Accordion

Single-open sections with a weighted spring.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/bouncy-accordion.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/bouncy-accordion/bouncy-accordion.tsx
    "use client";
    
    import { useId, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { ChevronDown } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration } from "@/lib/motion";
    
    export interface AccordionSection {
      /** Stable identity; also the value reported to onValueChange. */
      id: string;
      /** Header text. */
      title: ReactNode;
      /** Panel content. */
      content: ReactNode;
      /** Icon before the title. */
      icon?: ReactNode;
      /** Can't be opened. */
      disabled?: boolean;
    }
    
    export interface BouncyAccordionProps
      extends Omit<ComponentProps<"div">, "children" | "defaultValue" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Sections in order. */
      items: AccordionSection[];
      /** Controlled open section id, or null for none. */
      value?: string | null;
      /** Initially open section when uncontrolled. @default null */
      defaultValue?: string | null;
      /** Called with the newly open section id, or null when everything closes. */
      onValueChange?: (value: string | null) => void;
      /** Allow closing the open section so none is open. @default true */
      collapsible?: boolean;
      /** Heading level wrapping each header button. @default 3 */
      headingLevel?: 2 | 3 | 4;
    }
    
    // Height eases in on a heavy, barely-underdamped spring; the content settles with a little bounce.
    const HEIGHT = { type: "spring", stiffness: 210, damping: 27, mass: 1.15 } as const;
    const SETTLE = { type: "spring", stiffness: 320, damping: 17, mass: 0.9 } as const;
    
    export function BouncyAccordion({
      items,
      value,
      defaultValue = null,
      onValueChange,
      collapsible = true,
      headingLevel = 3,
      className,
      ...props
    }: BouncyAccordionProps) {
      const reduceMotion = useReducedMotion();
      const [internal, setInternal] = useState<string | null>(defaultValue);
      const openId = value === undefined ? internal : value;
      const baseId = useId();
      const headers = useRef<(HTMLButtonElement | null)[]>([]);
      const Heading = `h${headingLevel}` as const;
    
      const toggle = (id: string) => {
        const next = openId === id ? (collapsible ? null : id) : id;
        if (next === openId) return;
        if (value === undefined) setInternal(next);
        onValueChange?.(next);
      };
    
      // Arrow keys move between headers (WAI-ARIA accordion pattern).
      const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
        const enabled = items.map((item, i) => (item.disabled ? -1 : i)).filter((i) => i >= 0);
        const at = enabled.indexOf(index);
        const target = {
          ArrowDown: enabled[(at + 1) % enabled.length],
          ArrowUp: enabled[(at - 1 + enabled.length) % enabled.length],
          Home: enabled[0],
          End: enabled[enabled.length - 1],
        }[event.key];
        if (target === undefined) return;
        event.preventDefault();
        headers.current[target]?.focus();
      };
    
      return (
        <div className={cn("divide-y divide-border rounded-xl border border-border bg-background", className)} {...props}>
          {items.map((item, index) => {
            const open = openId === item.id;
            const headerId = `${baseId}-h-${index}`;
            const panelId = `${baseId}-p-${index}`;
            return (
              <div key={item.id}>
                <Heading className="m-0">
                  <button
                    ref={(node) => {
                      headers.current[index] = node;
                    }}
                    id={headerId}
                    type="button"
                    aria-expanded={open}
                    aria-controls={panelId}
                    disabled={item.disabled}
                    onClick={() => toggle(item.id)}
                    onKeyDown={(event) => onKeyDown(event, index)}
                    className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3.5 text-left text-[15px] font-medium text-foreground outline-none transition-colors first:rounded-t-xl hover:bg-surface focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:shrink-0"
                  >
                    {item.icon ? <span className="flex text-muted-foreground [&_svg]:size-[18px]">{item.icon}</span> : null}
                    <span className="flex-1">{item.title}</span>
                    <motion.span
                      aria-hidden="true"
                      className="flex text-muted-foreground"
                      animate={{ rotate: open ? 180 : 0 }}
                      transition={reduceMotion ? { duration: 0 } : SETTLE}
                    >
                      <ChevronDown className="size-4" />
                    </motion.span>
                  </button>
                </Heading>
                {/* The grid-rows trick sizes the panel to its content without measuring it. */}
                <motion.div
                  id={panelId}
                  role="region"
                  aria-labelledby={headerId}
                  inert={!open}
                  className="grid overflow-hidden"
                  initial={false}
                  animate={{ gridTemplateRows: open ? "1fr" : "0fr" }}
                  transition={reduceMotion ? { duration: 0 } : HEIGHT}
                >
                  <div className="min-h-0">
                    <motion.div
                      className="px-4 pb-4 text-sm leading-6 text-muted-foreground"
                      initial={false}
                      animate={open ? { opacity: 1, y: 0 } : { opacity: 0, y: reduceMotion ? 0 : -10 }}
                      transition={reduceMotion ? { duration: duration.quick } : { ...SETTLE, opacity: { duration: open ? duration.slow : duration.quick } }}
                    >
                      {item.content}
                    </motion.div>
                  </div>
                </motion.div>
              </div>
            );
          })}
        </div>
      );
    }

Usage

import { BouncyAccordion } from "@/components/ui/bouncy-accordion/bouncy-accordion";

export function Faq() {
  return (
    <BouncyAccordion
      items={[
        { id: "copy", title: "Can I copy components?", content: "Yes, every component is one file." },
        { id: "theme", title: "Will it match my theme?", content: "It reads shadcn's CSS variables." },
      ]}
    />
  );
}

API reference

PropTypeDefaultDescription
items*AccordionSection[]–Sections in order.
valuestring | null–Controlled open section id, or null for none.
defaultValuestring | nullnullInitially open section when uncontrolled.
onValueChange(value: string | null) => void–Called with the newly open section id, or null when everything closes.
collapsiblebooleantrueAllow closing the open section so none is open.
headingLevel2 | 3 | 43Heading level wrapping each header button.

Also accepts every native <div> attribute, including ref and className.

  • Height animates with the grid-rows trick (0fr → 1fr), so content of any height opens without measuring.
  • The height uses a heavy, barely-underdamped spring; the content settles with a little bounce on top of it.
  • Only one section is open at a time. Set collapsible={false} to always keep one open.

Accessibility

  • Follows the WAI-ARIA accordion pattern: header buttons with aria-expanded and aria-controls, panels with role=region, and Up/Down/Home/End moving between headers.
  • Closed panels are inert, so their links can't be tabbed to while hidden.
  • With reduced motion enabled, panels open and close instantly with a short fade.