Collapsible
One section of content that opens and closes.
@ieedan starred 3 packages
import { Div, H4, Span } from "@implementjs/core";
import { ChevronsUpDownIcon } from "@implementjs/lucide";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/lib/components/ui/collapsible";
function Repo(name: string) {
return Div({ class: "rounded-md border px-4 py-2 font-mono text-sm" }, name);
}
export default function CollapsibleDemo() {
return Collapsible(
{ class: "w-full max-w-md" },
Div(
{ class: "flex items-center justify-between gap-2 px-4" },
H4({ class: "text-sm font-semibold" }, "@ieedan starred 3 packages"),
CollapsibleTrigger(
{ size: "icon-sm" },
ChevronsUpDownIcon({ "aria-hidden": true, class: "size-4" }),
Span({ class: "sr-only" }, "Toggle"),
),
),
Repo("@implementjs/core"),
CollapsibleContent(
{ class: "flex flex-col gap-2" },
Repo("@implementjs/primitives"),
Repo("@implementjs/lucide"),
),
);
}Installation
npx jsrepo add @implementjs/ui/collapsible
jsrepo pulls button along with it.
Copy the file below to src/lib/components/ui/collapsible.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too.
import { Div, type Child, type ComponentProps } from "@implementjs/core";
import {
Collapsible as CollapsiblePrimitive,
CollapsibleContent as CollapsibleContentPrimitive,
CollapsibleTrigger as CollapsibleTriggerPrimitive,
} from "@implementjs/primitives";
import { buttonVariants, type ButtonSize, type ButtonVariant } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type CollapsibleProps = ComponentProps<typeof CollapsiblePrimitive>;
export type CollapsibleTriggerProps = ComponentProps<typeof CollapsibleTriggerPrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export type CollapsibleContentProps = ComponentProps<typeof CollapsibleContentPrimitive>;
export const Collapsible = createComponent(function Collapsible(
{ class: className, ...props }: CollapsibleProps,
...children: Child[]
) {
return CollapsiblePrimitive(
{ "data-slot": "collapsible", class: cn("flex flex-col gap-2", className), ...props },
...children,
);
});
export const CollapsibleTrigger = createComponent(function CollapsibleTrigger(
{
class: className,
variant = "ghost",
size = "default",
type = "button",
...props
}: CollapsibleTriggerProps,
...children: Child[]
) {
return CollapsibleTriggerPrimitive(
{
type,
...props,
"data-slot": "collapsible-trigger",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});
export const CollapsibleContent = createComponent(function CollapsibleContent(
{ class: className, ...props }: CollapsibleContentProps,
...children: Child[]
) {
return CollapsibleContentPrimitive(
{
...props,
"data-slot": "collapsible-content",
class:
"overflow-hidden text-sm motion-reduce:animate-none data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
},
Div({ class: className }, ...children),
);
});Usage
An accordion without the group: one trigger, one panel, no shared state. CollapsibleTrigger renders through the button styles and defaults to ghost, so it reads as a control without a filled background — pass variant and size to change that.
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/lib/components/ui/collapsible";
Collapsible(
CollapsibleTrigger({ size: "icon-sm" }, ChevronsUpDownIcon({ "aria-hidden": true })),
CollapsibleContent("@implementjs/kit", "@implementjs/primitives"),
);
Animation
The slide comes from the collapsible-down / collapsible-up keyframes in your stylesheet — the introduction has the block. Without them the panel shows and hides without animating.
CollapsibleContent puts your class on an inner Div, so padding you add does not fight the height animation.
API Reference
Every prop the styling does not consume is forwarded to the Collapsible primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
Collapsible
The root. Owns whether the content is open and provides that to the parts inside it. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
| Data attribute | Value |
|---|---|
[data-collapsible-root] | Present |
[data-state] | "open" | "closed" |
CollapsibleTrigger
Toggles the content open and closed. Renders a Button; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "ghost" | Which button style the part renders with. Also set as data-variant. |
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "default" | The button's height and padding scale. Also set as data-size. |
| Data attribute | Value |
|---|---|
[data-collapsible-trigger] | Present |
[data-state] | "open" | "closed" |
CollapsibleContent
The body. Hidden with the `hidden` attribute while closed; a close animation on it finishes before the attribute is set. class lands on an inner Div, so the animated element keeps the overflow and height classes it needs. The slide comes from the collapsible keyframes in your stylesheet. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
hiddenUntilFound | boolean | false | Closed content uses hidden="until-found" so find-in-page can reveal it. |
| Data attribute | Value |
|---|---|
[data-collapsible-content] | Present |
[data-state] | "open" | "closed" |
| CSS variable | Description |
|---|---|
--ip-collapsible-content-height | The natural height of the content, for open/close animations. |
--ip-collapsible-content-width | The natural width of the content, for open/close animations. |