diff --git a/packages/ui/src/components/collapsible.test.tsx b/packages/ui/src/components/collapsible.test.tsx
new file mode 100644
index 0000000..893634d
--- /dev/null
+++ b/packages/ui/src/components/collapsible.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "./collapsible"
+
+describe("Collapsible", () => {
+ it("connects the trigger to its region with aria relationships", () => {
+ render(
+
+ Advanced settings
+
+
+
+
+ )
+
+ const trigger = screen.getByRole("button", { name: "Advanced settings" })
+ const contentId = trigger.getAttribute("aria-controls")
+ const content = document.getElementById(contentId ?? "")
+
+ expect(trigger).toHaveAttribute("aria-expanded", "true")
+ expect(content?.tagName).toBe("SECTION")
+ expect(content).toHaveAttribute("aria-labelledby", trigger.id)
+ expect(content).toHaveAttribute("aria-hidden", "false")
+ })
+
+ it("toggles open and closed with pointer and keyboard input", async () => {
+ const user = userEvent.setup()
+ render(
+
+ Advanced settings
+
+
+
+
+ )
+
+ const trigger = screen.getByRole("button", { name: "Advanced settings" })
+
+ await user.click(trigger)
+ expect(trigger).toHaveAttribute("aria-expanded", "true")
+
+ trigger.focus()
+ await user.keyboard("{Enter}")
+ expect(trigger).toHaveAttribute("aria-expanded", "false")
+ })
+
+ it("supports controlled state", async () => {
+ const user = userEvent.setup()
+ const onOpenChange = vi.fn()
+
+ render(
+
+ Advanced settings
+
+
+
+
+ )
+
+ await user.click(screen.getByRole("button", { name: "Advanced settings" }))
+
+ expect(onOpenChange).toHaveBeenCalledWith(true)
+ })
+
+ it("keeps focusable content available without stealing focus from the trigger", async () => {
+ const user = userEvent.setup()
+ render(
+
+ Advanced settings
+
+
+
+
+ )
+
+ const trigger = screen.getByRole("button", { name: "Advanced settings" })
+ const input = screen.getByLabelText("Fee override")
+
+ trigger.focus()
+ await user.click(input)
+
+ expect(input).toHaveFocus()
+ expect(trigger).toHaveAttribute("aria-expanded", "true")
+ })
+})
diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx
new file mode 100644
index 0000000..09aeda5
--- /dev/null
+++ b/packages/ui/src/components/collapsible.tsx
@@ -0,0 +1,175 @@
+"use client"
+
+import * as React from "react"
+
+import { ChevronDownIcon } from "@hugeicons/core-free-icons"
+import { HugeiconsIcon } from "@hugeicons/react"
+import { cn } from "@workspace/ui/lib/utils"
+
+type CollapsibleLayout = "default" | "compact"
+
+type CollapsibleProps = Omit, "onChange"> & {
+ open?: boolean
+ defaultOpen?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+type CollapsibleContextValue = {
+ open: boolean
+ triggerId: string
+ contentId: string
+ toggleOpen: () => void
+}
+
+const CollapsibleContext = React.createContext(null)
+
+function useCollapsibleContext(component: string) {
+ const context = React.useContext(CollapsibleContext)
+ if (!context) {
+ throw new Error(`${component} must be used within Collapsible`)
+ }
+ return context
+}
+
+function Collapsible({
+ className,
+ children,
+ open,
+ defaultOpen = false,
+ onOpenChange,
+ ...props
+}: CollapsibleProps) {
+ const generatedId = React.useId()
+ const isControlled = open !== undefined
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
+
+ const isOpen = isControlled ? open : uncontrolledOpen
+ const triggerId = `${generatedId}-trigger`
+ const contentId = `${generatedId}-content`
+
+ const toggleOpen = React.useCallback(() => {
+ const nextOpen = !isOpen
+ if (!isControlled) {
+ setUncontrolledOpen(nextOpen)
+ }
+ onOpenChange?.(nextOpen)
+ }, [isControlled, isOpen, onOpenChange])
+
+ const contextValue = React.useMemo(
+ () => ({ open: isOpen, triggerId, contentId, toggleOpen }),
+ [contentId, isOpen, toggleOpen, triggerId]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+type CollapsibleTriggerProps = React.ComponentProps<"button"> & {
+ layout?: CollapsibleLayout
+}
+
+function CollapsibleTrigger({
+ className,
+ children,
+ layout = "default",
+ onClick,
+ onKeyDown,
+ ...props
+}: CollapsibleTriggerProps) {
+ const { open, triggerId, contentId, toggleOpen } = useCollapsibleContext(
+ "CollapsibleTrigger"
+ )
+ const suppressNextClickRef = React.useRef(false)
+
+ return (
+
+ )
+}
+
+type CollapsibleContentProps = React.ComponentProps<"div">
+
+function CollapsibleContent({
+ className,
+ children,
+ ...props
+}: CollapsibleContentProps) {
+ const { open, triggerId, contentId } = useCollapsibleContext(
+ "CollapsibleContent"
+ )
+
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+export type { CollapsibleProps, CollapsibleTriggerProps, CollapsibleContentProps }