corr
componentanimated-disclosuresource: ok · hit
Animated Disclosure
An expandable disclosure panel with vertical and horizontal motion directions.
npx shadcn add "https://ui.corr.sh/r/animated-disclosure.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumiconslucide-react
animationmotion
Registry dependencies
button
Files1 · source cached 10/8/2026, 5:17:07 PM
registry/corr/components/animated-disclosure.tsx
"use client"
import type { ReactNode } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export type AnimatedDisclosureDirection = "down" | "up" | "left" | "right"
export function AnimatedDisclosure({
open,
onOpenChange,
title,
children,
direction = "down",
trigger,
disabled = false,
className,
triggerClassName,
contentClassName,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: ReactNode
children: ReactNode
direction?: AnimatedDisclosureDirection
trigger?: ReactNode
disabled?: boolean
className?: string
triggerClassName?: string
contentClassName?: string
}) {
const reduceMotion = useReducedMotion()
const isHorizontal = direction === "left" || direction === "right"
const label = typeof title === "string" ? title : "Toggle disclosure"
if (isHorizontal) {
return (
<div
className={cn(
"inline-flex items-center gap-2",
direction === "left" && "flex-row-reverse",
className
)}
>
<Button
type="button"
size="icon"
aria-expanded={open}
aria-label={label}
disabled={disabled}
onClick={() => onOpenChange(!open)}
className={cn(
"size-10 shrink-0 rounded-full shadow-sm",
triggerClassName
)}
>
{trigger ?? <ChevronDown className="size-4" />}
</Button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={
reduceMotion
? { opacity: 0 }
: {
opacity: 0,
width: 0,
x: direction === "left" ? 6 : -6,
}
}
animate={
reduceMotion
? { opacity: 1 }
: { opacity: 1, width: "auto", x: 0 }
}
exit={
reduceMotion
? { opacity: 0 }
: {
opacity: 0,
width: 0,
x: direction === "left" ? 6 : -6,
}
}
transition={{
duration: reduceMotion ? 0 : 0.24,
ease: "easeOut",
}}
className="overflow-hidden"
>
<div
className={cn(
"w-72 rounded-md border bg-card p-4 shadow-sm",
contentClassName
)}
>
<div className="text-sm font-medium">{title}</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">
{children}
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
const content = (
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
animate={
reduceMotion ? { opacity: 1 } : { height: "auto", opacity: 1 }
}
exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.22, ease: "easeOut" }}
className="overflow-hidden"
>
<div
className={cn(
"px-4 py-3 text-sm leading-6 text-muted-foreground",
direction === "up" ? "border-b" : "border-t",
contentClassName
)}
>
{children}
</div>
</motion.div>
) : null}
</AnimatePresence>
)
return (
<div className={cn("rounded-md border bg-card", className)}>
{direction === "up" ? content : null}
<Button
type="button"
variant="ghost"
aria-expanded={open}
disabled={disabled}
onClick={() => onOpenChange(!open)}
className={cn(
"flex h-auto w-full justify-between rounded-none px-4 py-3 text-left",
triggerClassName
)}
>
<span className="font-medium">{title}</span>
<motion.span
animate={{
rotate: direction === "up" ? (open ? 0 : 180) : open ? 180 : 0,
}}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.18, ease: "easeOut" }
}
>
<ChevronDown className="size-4 text-muted-foreground" />
</motion.span>
</Button>
{direction === "down" ? content : null}
</div>
)
}