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.json

Live 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

PropTypeDefaultDescription
title / descriptionReact.ReactNodeHeader title and supporting text.
leftIcon / rightIconsPanelIconAction / PanelIconAction[]Header icon buttons with optional tooltips.
leftContent / rightContentReact.ReactNodeFree-form header slots.
leftDetails / rightDetailsPanelDetail[]Badge, icon, and text metadata.
footerReact.ReactNodeFooter action row.
variant"default" | "elevated" | "borderless"defaultBorder/shadow treatment.
collapsiblebooleanfalseMakes the header a toggle and animates the content.
open / defaultOpenbooleantrueControlled / uncontrolled open state.
onOpenChange(open: boolean) => voidFires when the panel is toggled.
noPaddingbooleanfalseRemoves default body padding.
disabledbooleanfalseDisables 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

Browse the full examples gallery