Browse the library
Components / Layout
Smooth accordion
FreeAccessible FAQ accordion with smooth height animation, a plus to minus icon morph and single or multiple mode.
View as Markdownmotion
components/facade/smooth-accordion.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface AccordionItem {
/** Unique id. */
value: string;
/** Question or header text. */
title: React.ReactNode;
/** Answer or body content. */
content: React.ReactNode;
}
export interface SmoothAccordionProps {
/** Accordion entries. Defaults to a short FAQ example. */
items?: AccordionItem[];
/** "single" keeps one item open at a time. Defaults to "single". */
type?: "single" | "multiple";
/** Items open on first render. */
defaultValue?: string[];
/** Controlled open items. */
value?: string[];
onValueChange?: (value: string[]) => void;
/** Heading level used for each header. Defaults to 3. */
headingLevel?: 2 | 3 | 4;
className?: string;
itemClassName?: string;
}
const ease = [0.22, 1, 0.36, 1] as const;
function PlusMinus({ open, reduce }: { open: boolean; reduce: boolean }) {
return (
<span
aria-hidden
className={cn(
"relative inline-flex size-7 shrink-0 items-center justify-center rounded-full border transition-colors duration-300",
open ? "border-brand/30 bg-brand/10 text-brand" : "border-border bg-background text-muted-foreground",
)}
>
<span className="absolute h-[1.5px] w-3 rounded-full bg-current" />
<motion.span
className="absolute h-3 w-[1.5px] rounded-full bg-current"
initial={false}
animate={{ rotate: open ? 90 : 0, scaleY: open ? 0 : 1 }}
transition={reduce ? { duration: 0 } : { duration: 0.35, ease }}
/>
</span>
);
}
const DEFAULT_ITEMS: AccordionItem[] = [
{
value: "estimate",
title: "How fast can I get an estimate?",
content: "Most estimates go out the same day. Send a few photos and your ZIP code and we will reply with a written, flat-rate quote.",
},
{
value: "insured",
title: "Are you licensed and insured?",
content: "Yes. We are fully licensed, bonded and insured, and we are happy to share certificates before any work starts.",
},
{
value: "warranty",
title: "What does the warranty cover?",
content: "Labor is covered for two years and materials carry the manufacturer warranty. If something is not right, we come back and fix it.",
},
];
export function SmoothAccordion({
items = DEFAULT_ITEMS,
type = "single",
defaultValue = [],
value,
onValueChange,
headingLevel = 3,
className,
itemClassName,
}: SmoothAccordionProps) {
const reduce = useReducedMotion() ?? false;
const id = React.useId();
const [internal, setInternal] = React.useState<string[]>(defaultValue);
const open = value ?? internal;
const Heading = `h${headingLevel}` as "h2" | "h3" | "h4";
const toggle = (v: string) => {
const isOpen = open.includes(v);
const next = type === "single" ? (isOpen ? [] : [v]) : isOpen ? open.filter((x) => x !== v) : [...open, v];
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
return (
<div className={cn("divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card text-card-foreground", className)}>
{items.map((item) => {
const isOpen = open.includes(item.value);
const triggerId = `${id}-trigger-${item.value}`;
const panelId = `${id}-panel-${item.value}`;
return (
<div key={item.value} className={cn("group/item", itemClassName)}>
<Heading className="m-0">
<button
type="button"
id={triggerId}
aria-expanded={isOpen}
aria-controls={isOpen ? panelId : undefined}
onClick={() => toggle(item.value)}
className={cn(
"flex w-full items-center justify-between gap-6 px-5 py-4 text-left text-[15px] font-medium tracking-[-0.01em] transition-colors sm:px-6 sm:py-5",
"hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
isOpen ? "text-foreground" : "text-foreground/85",
)}
>
<span>{item.title}</span>
<PlusMinus open={isOpen} reduce={reduce} />
</button>
</Heading>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
key="panel"
id={panelId}
role="region"
aria-labelledby={triggerId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={
reduce
? { duration: 0 }
: { height: { duration: 0.4, ease }, opacity: { duration: 0.25, ease } }
}
className="overflow-hidden"
>
<div className="px-5 pb-5 pr-14 text-sm leading-relaxed text-muted-foreground sm:px-6 sm:pr-16">
{item.content}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
})}
</div>
);
}
export default SmoothAccordion;app/page.tsx
import { MessageCircle } from "lucide-react";
import { SmoothAccordion } from "@/components/facade/smooth-accordion";
const faqs = [
{
value: "supplies",
title: "Do I need to provide cleaning supplies?",
content:
"No. Our team arrives with everything, including HEPA vacuums and plant based products. If you prefer a specific brand, leave it out and we will use it.",
},
{
value: "home",
title: "Do I have to be home during the cleaning?",
content:
"Most clients are not. You can leave a key, share a door code or use a lockbox. Every cleaner is background checked, bonded and insured up to $1M.",
},
{
value: "price",
title: "How is pricing calculated?",
content:
"Flat rates based on bedrooms and bathrooms, starting at $129 for a one bedroom apartment. You see the exact price before you book, no hourly surprises.",
},
{
value: "guarantee",
title: "What if I am not happy with the clean?",
content:
"Tell us within 24 hours and we will come back to re-clean the missed areas for free. If it is still not right, we refund the visit.",
},
{
value: "reschedule",
title: "Can I reschedule or skip a visit?",
content: "Yes, anytime up to 24 hours before your appointment from your account or by text. No fees.",
},
];
export default function Demo() {
return (
<section className="grid w-full max-w-4xl gap-8 md:grid-cols-[1fr_1.6fr]">
<div>
<p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">Tidy Nest, Denver</p>
<h3 className="mt-2 font-display text-2xl font-semibold tracking-tight sm:text-3xl">Questions, answered</h3>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Everything you need to know before your first clean. Still unsure? Our Denver team replies in minutes.
</p>
<a
href="#"
className="mt-5 inline-flex h-10 items-center gap-2 rounded-full border border-border bg-card px-4 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<MessageCircle className="size-4 text-brand" aria-hidden />
Text us a question
</a>
</div>
<SmoothAccordion items={faqs} defaultValue={["supplies"]} />
</section>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/smooth-accordion.jsonUsage
import { SmoothAccordion } from "@/components/facade/smooth-accordion";
export default function Page() {
return <SmoothAccordion />;
}Props
SmoothAccordionProps is exported, so you can extend or wrap it. Every prop has a default; <SmoothAccordion /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| items | AccordionItem[] | Accordion entries. Defaults to a short FAQ example. |
| type | "single" | "multiple" | "single" keeps one item open at a time. Defaults to "single". |
| defaultValue | string[] | Items open on first render. |
| value | string[] | Controlled open items. |
| onValueChange | (value: string[]) => void | |
| headingLevel | 2 | 3 | 4 | Heading level used for each header. Defaults to 3. |
| className | string | |
| itemClassName | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade smooth accordion to this page and adapt the copy to our business.