niceui

Bouncy Accordion

An accordion with spring-eased panels, a rotating plus icon and single or multiple open modes.

ReactTailwind CSSTypeScriptComponentsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/components-bouncy-accordion.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/ui/motion/BouncyAccordion.tsx
import { useState, type ReactNode } from 'react';
import { Plus } from 'lucide-react';
import { cn } from '@/lib/cn';

export type AccordionItem = { id: string; title: string; content: ReactNode };

export type BouncyAccordionProps = {
  items: AccordionItem[];
  type?: 'single' | 'multiple';
  defaultOpen?: string[];
  className?: string;
};

export function BouncyAccordion({ items, type = 'single', defaultOpen = [], className }: BouncyAccordionProps) {
  const [open, setOpen] = useState<string[]>(defaultOpen);

  const toggle = (id: string) =>
    setOpen((prev) => {
      if (prev.includes(id)) return prev.filter((p) => p !== id);
      return type === 'single' ? [id] : [...prev, id];
    });

  return (
    <div className={cn('flex flex-col gap-2', className)}>
      {items.map((item, i) => {
        const isOpen = open.includes(item.id);
        return (
          <div
            key={item.id}
            style={{ animationDelay: `${i * 60}ms` }}
            className={cn(
              'animate-fade-up overflow-hidden rounded-xl bg-ink-900 transition-all duration-500 ease-spring',
              isOpen ? 'shadow-pop' : 'shadow-hairline hover:bg-ink-850',
            )}
          >
            <button
              onClick={() => toggle(item.id)}
              aria-expanded={isOpen}
              className="focus-ring flex w-full items-center justify-between gap-4 rounded-xl px-4 py-3.5 text-left"
            >
              <span className={cn('text-sm font-medium transition-colors', isOpen ? 'text-ink-50' : 'text-ink-200')}>
                {item.title}
              </span>
              <span
                className={cn(
                  'flex h-6 w-6 shrink-0 items-center justify-center rounded-full transition-all duration-500 ease-spring',
                  isOpen ? 'rotate-[135deg] bg-brand-500 text-white' : 'bg-ink-800 text-ink-300',
                )}
              >
                <Plus className="h-3.5 w-3.5" />
              </span>
            </button>
            <div
              className={cn(
                'grid transition-[grid-template-rows] ease-spring',
                isOpen ? 'grid-rows-[1fr] duration-[650ms]' : 'grid-rows-[0fr] duration-300',
              )}
            >
              <div className="min-h-0 overflow-hidden">
                <div
                  className={cn(
                    'px-4 pb-4 text-sm leading-relaxed text-ink-300 transition-all ease-spring',
                    isOpen ? 'translate-y-0 scale-100 opacity-100 duration-500 delay-75' : '-translate-y-3 scale-[0.98] opacity-0 duration-200',
                  )}
                >
                  {item.content}
                </div>
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

const FAQ: AccordionItem[] = [
  {
    id: 'license',
    title: 'Can I use NiceUI in commercial projects?',
    content: 'Yes. Every component is MIT licensed — ship it in client work, SaaS products and internal tools without attribution.',
  },
  {
    id: 'deps',
    title: 'Does it depend on an animation library?',
    content: 'No. All motion is built on CSS transitions, keyframes and a few requestAnimationFrame loops, so the bundle stays tiny.',
  },
  {
    id: 'a11y',
    title: 'Is it accessible?',
    content: 'Components ship with keyboard support, ARIA attributes and respect prefers-reduced-motion out of the box.',
  },
  {
    id: 'theme',
    title: 'How do I customize the theme?',
    content: 'Override the ink and brand scales in tailwind.config.js. Every component reads from those tokens.',
  },
];

export function BouncyAccordionDemo() {
  return (
    <div className="mx-auto w-full max-w-md">
      <BouncyAccordion items={FAQ} defaultOpen={['deps']} />
    </div>
  );
}

export function BouncyAccordionMultipleDemo() {
  return (
    <div className="mx-auto w-full max-w-md">
      <BouncyAccordion items={FAQ.slice(0, 3)} type="multiple" defaultOpen={['license', 'a11y']} />
    </div>
  );
}