Composite
Panel
Panel provides a card shell with optional collapsibility and props for title, description, icon actions, badge details, and footer content.
Installation
npx shadcn add https://rck.vibeboxph.com//r/panel.jsonLive preview & controls
Panel title
Supporting description text.
Panel body content goes here.
Controls
<Panel
title="Panel title"
description="Supporting description text."
>
Panel body content goes here.
</Panel>Anatomy
PanelThe root card container with header, body, and optional footer.title / descriptionHeader typography passed as props.leftDetails / rightDetailsBadge, icon, and text metadata rows.leftIcon / rightIconsHeader icon actions with optional tooltips.childrenMain panel body; collapsible when enabled.footerBottom-aligned actions row.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title / description | React.ReactNode | — | Header title and supporting text. |
| leftIcon / rightIcons | PanelIconAction / PanelIconAction[] | — | Header icon buttons with optional tooltips. |
| leftContent / rightContent | React.ReactNode | — | Free-form header slots. |
| leftDetails / rightDetails | PanelDetail[] | — | Badge, icon, and text metadata. |
| footer | React.ReactNode | — | Footer action row. |
| variant | "default" | "elevated" | "borderless" | default | Border/shadow treatment. |
| collapsible | boolean | false | Makes the header a toggle and animates the content. |
| open / defaultOpen | boolean | true | Controlled / uncontrolled open state. |
| onOpenChange | (open: boolean) => void | — | Fires when the panel is toggled. |
| noPadding | boolean | false | Removes default body padding. |
| disabled | boolean | false | Disables header interactions. |
Accessibility
- When `collapsible`, the header becomes a real button with `aria-expanded`.
- The chevron rotates as a visual cue in addition to the `aria-expanded` state change.
- Non-collapsible panels render plain, non-interactive headers unless `onHeaderClick` is provided.
- Icon actions expose `aria-label` from `label` or `tooltip`.
Source
registry/rck/ui/panel.tsx
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@/registry/rck/primitives/collapsible";
import { cva, type VariantProps } from "@/lib/cva";
import { ChevronDown } from "@/lib/icons";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/hooks/use-controllable-state";
import { Badge, type BadgeProps } from "@/registry/rck/ui/badge";
import { Icon, type IconName } from "@/registry/rck/ui/icon";
import { SimpleTooltip } from "@/registry/rck/ui/tooltip";
export const panelVariants = cva("rounded-lg bg-card text-card-foreground", {
variants: {
variant: {
default: "border border-border shadow-sm",
elevated: "border border-border shadow-md",
borderless: "border-0 shadow-none",
},
},
defaultVariants: {
variant: "default",
},
});
export type PanelDetail = {
value?: React.ReactNode;
badgeVariant?: BadgeProps["variant"];
icon?: IconName;
text?: string;
onClick?: () => void;
};
export type PanelIconAction = {
icon: IconName;
label?: string;
tooltip?: string;
onClick?: () => void;
};
export interface PanelProps
extends
Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
VariantProps<typeof panelVariants> {
title?: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
leftIcon?: PanelIconAction;
rightIcons?: PanelIconAction[];
leftContent?: React.ReactNode;
rightContent?: React.ReactNode;
leftDetails?: PanelDetail[];
rightDetails?: PanelDetail[];
collapsible?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onHeaderClick?: () => void;
noPadding?: boolean;
disabled?: boolean;
}
function PanelIconButton({
action,
disabled,
}: {
action: PanelIconAction;
disabled?: boolean;
}) {
const button = (
<button
type="button"
className={cn(
"text-muted-foreground inline-flex items-center gap-1 rounded-md p-1 transition-colors",
"hover:bg-surface-2 hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
action.onClick && !disabled && "cursor-pointer",
)}
aria-label={action.label ?? action.tooltip}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
if (!disabled) action.onClick?.();
}}
>
{action.label ? <span className="text-xs">{action.label}</span> : null}
<Icon name={action.icon} size="sm" />
</button>
);
if (action.tooltip) {
return <SimpleTooltip content={action.tooltip}>{button}</SimpleTooltip>;
}
return button;
}
function PanelDetailItem({
detail,
align = "left",
disabled,
}: {
detail: PanelDetail;
align?: "left" | "right";
disabled?: boolean;
}) {
const clickable = !!detail.onClick && !disabled;
return (
<div
className={cn(
"text-muted-foreground inline-flex items-center gap-1.5 text-xs",
align === "right" && "justify-end",
clickable && "hover:text-foreground cursor-pointer rounded-md",
)}
onClick={(event) => {
if (disabled) return;
event.stopPropagation();
detail.onClick?.();
}}
>
{detail.value != null ? (
<Badge variant={detail.badgeVariant ?? "soft"} size="sm">
{detail.value}
</Badge>
) : null}
{detail.icon ? <Icon name={detail.icon} size="xs" /> : null}
{detail.text ? <span>{detail.text}</span> : null}
</div>
);
}
export const Panel = React.forwardRef<HTMLDivElement, PanelProps>(
(
{
className,
variant,
title,
description,
children,
footer,
leftIcon,
rightIcons = [],
leftContent,
rightContent,
leftDetails = [],
rightDetails = [],
collapsible = false,
open,
defaultOpen = true,
onOpenChange,
onHeaderClick,
noPadding = false,
disabled = false,
...props
},
ref,
) => {
const [isOpen, setIsOpen] = useControllableState<boolean>({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const hasHeader =
!!title ||
!!description ||
!!leftIcon ||
rightIcons.length > 0 ||
!!leftContent ||
!!rightContent ||
leftDetails.length > 0 ||
rightDetails.length > 0;
const headerInner = (
<>
{(leftIcon || leftContent || leftDetails.length > 0) && (
<div className="flex shrink-0 items-center gap-2">
{leftIcon ? <PanelIconButton action={leftIcon} disabled={disabled} /> : null}
{leftContent ? (
<div aria-hidden={disabled ? true : undefined}>{leftContent}</div>
) : null}
{leftDetails.map((detail, index) => (
<PanelDetailItem
key={`left-${index}`}
detail={detail}
disabled={disabled}
/>
))}
</div>
)}
{(title || description) && (
<div className="min-w-0 flex-1">
{title ? (
<h3 data-slot="panel-title" className="text-sm leading-none font-semibold">
{title}
</h3>
) : null}
{description ? (
<p
data-slot="panel-description"
className={cn(
"text-muted-foreground text-sm",
title ? "mt-1" : undefined,
)}
>
{description}
</p>
) : null}
</div>
)}
{(rightContent ||
rightDetails.length > 0 ||
rightIcons.length > 0 ||
collapsible) && (
<div className="flex shrink-0 items-center gap-2">
{rightContent ? (
<div aria-hidden={disabled ? true : undefined}>{rightContent}</div>
) : null}
{rightDetails.map((detail, index) => (
<PanelDetailItem
key={`right-${index}`}
detail={detail}
align="right"
disabled={disabled}
/>
))}
{rightIcons.map((action, index) => (
<PanelIconButton
key={`icon-${index}`}
action={action}
disabled={disabled}
/>
))}
{collapsible ? (
<ChevronDown
className={cn(
"text-muted-foreground size-4 transition-transform",
isOpen && "rotate-180",
)}
aria-hidden="true"
/>
) : null}
</div>
)}
</>
);
const header = hasHeader ? (
collapsible ? (
<CollapsiblePrimitive.Trigger
data-slot="panel-header"
className={cn(
"font-inherit flex w-full cursor-pointer appearance-none items-start justify-between gap-3 p-4 text-left text-inherit select-none",
"focus-visible:ring-ring/50 rounded-t-lg bg-transparent focus-visible:ring-2 focus-visible:outline-none",
disabled && "pointer-events-none opacity-50",
onHeaderClick && "cursor-pointer",
)}
aria-expanded={isOpen}
disabled={disabled}
onClick={() => {
if (!disabled) onHeaderClick?.();
}}
>
{headerInner}
</CollapsiblePrimitive.Trigger>
) : (
<div
data-slot="panel-header"
className={cn(
"flex items-start justify-between gap-3 p-4",
onHeaderClick && !disabled && "cursor-pointer",
disabled && "opacity-50",
)}
onClick={() => {
if (!disabled) onHeaderClick?.();
}}
>
{headerInner}
</div>
)
) : null;
const body = (
<div
data-slot="panel-content"
className={cn(
"text-sm",
!noPadding && "px-4 pb-4",
hasHeader && !noPadding && "pt-0",
)}
>
{children}
</div>
);
const content = collapsible ? (
<CollapsiblePrimitive.Content className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden">
{body}
</CollapsiblePrimitive.Content>
) : (
body
);
const footerNode = footer ? (
<div
data-slot="panel-footer"
className="border-border flex items-center justify-end gap-2 border-t px-4 py-3"
>
{footer}
</div>
) : null;
const panelBody = (
<div
ref={ref}
data-slot="panel"
data-disabled={disabled ? "" : undefined}
className={cn(panelVariants({ variant }), className)}
{...props}
>
{header}
{content}
{footerNode}
</div>
);
if (!collapsible) {
return panelBody;
}
return (
<CollapsiblePrimitive.Root open={isOpen} onOpenChange={setIsOpen} asChild>
{panelBody}
</CollapsiblePrimitive.Root>
);
},
);
Panel.displayName = "Panel";
hooks/use-controllable-state.ts
import * as React from "react";
export interface UseControllableStateProps<T> {
prop?: T;
defaultProp?: T;
onChange?: (value: T) => void;
}
/**
* Shared controlled/uncontrolled state pattern used across interactive
* components (Accordion, Tabs, Dropdown, DatePicker, Modal, Panel, Alert).
* Pass `value`/`onValueChange` to control the component; omit `value` (use
* `defaultValue`) to let it manage its own state.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateProps<T>): [T, (next: T | ((prev: T) => T)) => void] {
const [uncontrolledState, setUncontrolledState] = React.useState<T | undefined>(
defaultProp,
);
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledState;
const onChangeRef = React.useRef(onChange);
React.useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setValue = React.useCallback(
(next: T | ((prev: T) => T)) => {
if (isControlled) {
const resolved =
typeof next === "function" ? (next as (prev: T) => T)(prop as T) : next;
if (resolved !== prop) {
onChangeRef.current?.(resolved);
}
} else {
setUncontrolledState((prev) => {
const resolved =
typeof next === "function" ? (next as (prev: T) => T)(prev as T) : next;
if (resolved !== prev) {
queueMicrotask(() => onChangeRef.current?.(resolved));
}
return resolved;
});
}
},
[isControlled, prop],
);
return [value as T, setValue];
}
Related examples
HeaderLeading visual, title and description.
MetadataA metadata row combining text and a badge.
ActionsHeader and footer action buttons.
Visual variantsDefault, elevated and borderless.