PVR Tech Studio

Panel (Portlet)

Luminaux's flagship portlet card — collapsible, refreshable, maximizable and closable, with a header toolbar, overflow menu, five themeable header variants, equal-height grids and drag-to-reorder support.

10 min read
Updated July 15, 2026

Overview

Panel is a collapsible "portlet" card with a header action toolbar — the workhorse container for dashboard widgets and grouped content. Its header carries an optional set of tools that appear only when you opt in: collapse/expand, refresh, maximize (full-screen), close, an overflow menu, and a drag handle. The body animates open/closed via Collapse, and everything is token-based and reduced-motion safe.

Two things make Panel distinctive in Luminaux:

  1. The title and subtitle are both mandatory. subtitle is a required prop — the project convention is no title-only panels. A panel always presents a heading and a short descriptor beneath it.
  2. It is drag-and-drop-agnostic. Panel never imports @dnd-kit. Instead it accepts a dragHandleProps bag that you spread from a sortable hook; when present, a grip handle appears. This keeps the component reusable with any DnD library (or none).

Architecture & files

FileResponsibility
src/components/ui/Panel.tsxThe Panel component, its internal PanelAction icon button, the RefreshOverlay loading overlay, and the PanelHeaderVariant type.
src/pages/ui/PanelsPage.tsxThe /ui/panels demo — a sortable grid built on @dnd-kit with a DragOverlay, dashed drop placeholder, refresh, maximize, close, duplicate, and a "restore panels" empty state.
src/context/LayoutContext.tsxOwns the global defaults panelHeaderVariant and panelEqualHeight (persisted layout config).

Key dependencies: motion/react (chevron rotation, maximize overlay), lucide-react (ChevronDown, RefreshCw, Maximize2, Minimize2, MoreHorizontal, GripVertical, X), react-dom createPortal (maximize), the Collapse motion wrapper, the Dropdown overlay (⋯ menu — see Overlays), the Tooltip primitive, and useDismiss (Escape/click-outside for the maximized overlay).

Anatomy

┌───────────────────────────────────────────────┐  ← header (headerVariant chrome)
│ ⠿  Title                       ⋯ ⟳ ⤢ ⌄ ×      │     grip · title/subtitle · toolbar
│    Subtitle                                    │
├───────────────────────────────────────────────┤  ← border-t
│  children                                      │  ← body (Collapse; RefreshOverlay when refreshing)
└───────────────────────────────────────────────┘

The toolbar renders left→right: menu (if menu), refresh (if refreshable), maximize (if maximizable), collapse chevron (if collapsible), close (if closable). The grip handle sits at the far left, before the title, and only appears when dragHandleProps is set.

Usage

import {Panel} from '@/components/ui'
 
<Panel title="Revenue" subtitle="Last 30 days">
    <RevenueChart />
</Panel>

Opt into tools by flag; wire their callbacks:

<Panel
    title="Traffic sources"
    subtitle="This week"
    refreshable
    maximizable
    closable
    onRefresh={reload}
    onClose={() => hide('traffic')}
    menu={
        <>
            <DropdownItem onSelect={configure}>Configure</DropdownItem>
            <DropdownSeparator />
            <DropdownItem tone="danger" onSelect={() => hide('traffic')}>Remove</DropdownItem>
        </>
    }
>
    <TrafficBars />
</Panel>

API / Props

PanelProps

PropTypeDefaultDescription
titleReactNode— (required)The panel heading.
subtitleReactNode— (required)Descriptor under the title. Required by convention — no title-only panels.
childrenReactNode— (required)Body content.
collapsiblebooleantrueShow the chevron that toggles the body via <Collapse>.
refreshablebooleanfalseShow a refresh button that spins while onRefresh runs.
maximizablebooleanfalseShow a maximize button that opens the panel full-screen.
closablebooleanfalseShow a close (×) button that calls onClose.
defaultCollapsedbooleanfalseStart collapsed.
onRefresh() => void | Promise<void>Runs on refresh; the icon spins until it resolves.
onClose() => voidCalled by the close (×) button.
menuReactNodeDropdownItems shown under a overflow trigger.
dragHandlePropsRecord<string, unknown>dnd-kit {...attributes, ...listeners} — spread onto the grip handle. When set, a drag handle shows. Stays dnd-kit-agnostic.
headerVariantPanelHeaderVariant'default'Header chrome — see Header variants.
fillbooleanfalseFill container height (h-full) so panels in a grid row match the tallest. Per-panel override of the global equal-height setting.
classNamestringExtra classes on the (non-maximized) panel shell.

Header variants

PanelHeaderVariant = 'default' | 'muted' | 'dark' | 'black' | 'skin'. Each re-chromes only the header; the body stays on the standard surface.

ValueAppearanceMatches which sidebar
defaultStandard theme surface (bg-surface), no rounded/tinted header.— (baseline)
mutedTinted header (bg-surface-muted), rounded top.
darkForced-dark header — re-scopes the base dark tokens (.dark bg-surface).The dark sidebar (darkSidebar).
blackTrue-black header — adds sidebar-oled for the pure-black tokens (.dark sidebar-oled bg-surface).The true-black / OLED sidebar (oledSidebar).
skinAccent header that follows the active skinbg-primary / text-primary-foreground. Action buttons and grip switch to primary-foreground tints.— (tracks the current skin's primary)

dark and black work by placing .dark (and sidebar-oled) on the header element itself, so they reuse the exact same token blocks the dark/OLED sidebars use — no duplicated hex (see Design tokens & dark mode). skin reads --primary / --primary-foreground, so it re-colors automatically with every skin (see Design skins). The refresh icon's active tint (text-primary) is suppressed on the skin header, where the header is already primary-colored.

Configuration & customization

Global defaults

Two LayoutContext config flags set app-wide panel defaults (persisted with the rest of the layout config):

Config keyTypeDefaultMeaning
config.panelHeaderVariantPanelHeaderVariant'default'Default header chrome for panels that read it.
config.panelEqualHeightbooleanfalseWhether panels in a grid row stretch to equal height.

These are edited from the Layout Customizer "Panels" section and the Layout Settings → Appearance tab (see Customizer & settings). The Panel component does not read these globals itself — the caller decides the fallback. The demo wires it explicitly:

const {config} = useLayout()
const headerVariant = config.panelHeaderVariant
// …
<Panel headerVariant={headerVariant} fill />

This keeps Panel a pure presentational primitive: pass headerVariant/fill from config when you want the panel to honor the global setting, or hardcode them when you don't.

Equal-height / fill

Set fill to make the panel take h-full. In a CSS grid row, that stretches every panel to the height of the tallest one, so a row of dashboard widgets lines up cleanly. Wire it to config.panelEqualHeight for the global behavior, or pass it directly. The demo grid also wraps each sortable item in an h-full cell so fill has a height to fill.

Refresh & the loading overlay

When refreshable and the user clicks refresh, handleRefresh:

  1. Ignores the click if a refresh is already running (no double-fire).
  2. Sets refreshing → the RefreshCw icon tints primary and a RefreshOverlay (a centered spinner over a translucent bg-surface/70 backdrop) covers the body.
  3. Races onRefresh() against a 15-second safety cap so a hung refresh eventually clears on its own, and also enforces a ~800 ms minimum spin so a fast refresh doesn't just flash.
  4. Clears refreshing once both settle — but only if the component is still mounted (guarded by a mounted ref).

The RefreshOverlay reuses the minimal route-loader spinner (border-t-primary ring) so refresh feedback matches the rest of the app.

Maximize (full-screen)

With maximizable, the maximize button opens the panel full-screen. This renders a second copy of the shell into a document.body portal, over a blurred bg-foreground/40 scrim, animated in/out with AnimatePresence. Escape and click-outside close it via useDismiss (the overlay's inner container holds the dismiss ref). The maximized body uses a scrollable flex-1 overflow-auto region instead of Collapse, so tall content scrolls inside the overlay.

Drag-to-reorder with @dnd-kit

Panel stays dnd-kit-agnostic: it exposes dragHandleProps and renders a grip handle when you pass it. You own the DnD wiring. The /ui/panels demo shows the full pattern:

  • A DndContext with PointerSensor (activationConstraint: {distance: 5}) + a KeyboardSensor (sortableKeyboardCoordinates, custom keyboardCodes).
  • A SortableContext using rectSortingStrategy (grid), with closestCorners collision detection.
  • Each panel wrapped in a useSortable cell; {...attributes, ...listeners} are spread into dragHandleProps, and transform/transition onto the cell.
  • While a cell is dragging, it swaps the Panel for a dashed drop placeholder (border-2 border-dashed border-primary/50 bg-primary/5) that slides to the hovered slot.
  • A DragOverlay renders the floating panel; since the overlay isn't sortable, it passes an empty dragHandleProps={{}} just so the decorative grip stays visible while moving.

Examples

A fully-loaded dashboard panel

<Panel
    title="Storage"
    subtitle="64.2 GB of 100 GB used"
    headerVariant="muted"
    refreshable
    maximizable
    closable
    onRefresh={fetchStorage}
    onClose={() => dismiss('storage')}
    menu={
        <>
            <DropdownItem onSelect={openDetails}>View details</DropdownItem>
            <DropdownSeparator />
            <DropdownItem tone="danger" onSelect={() => dismiss('storage')}>Remove</DropdownItem>
        </>
    }
>
    <Progress value={64} tone="primary" showValue />
</Panel>

Sortable, equal-height grid (the /ui/panels pattern)

function SortablePanel({item, headerVariant, onClose, onDuplicate}: SortablePanelProps) {
    const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({id: item.id})
 
    return (
        <div
            ref={setNodeRef}
            style={{transform: CSS.Transform.toString(transform), transition}}
            className={cn('h-full', isDragging && 'relative z-10')}
        >
            {isDragging ? (
                <div className="h-full min-h-28 rounded-lg border-2 border-dashed border-primary/50 bg-primary/5" />
            ) : (
                <Panel
                    title={item.title}
                    subtitle={item.subtitle}
                    refreshable
                    maximizable
                    closable
                    headerVariant={headerVariant}
                    fill
                    dragHandleProps={{...attributes, ...listeners}}
                    onRefresh={fakeRefresh}
                    onClose={() => onClose(item.id)}
                    menu={/* Duplicate · Settings · Remove */}
                >
                    {item.body}
                </Panel>
            )}
        </div>
    )
}
 
// …inside the page:
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={…} onDragEnd={…}>
    <SortableContext items={panels.map((p) => p.id)} strategy={rectSortingStrategy}>
        <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
            {panels.map((p) => <SortablePanel key={p.id} item={p}  />)}
        </div>
    </SortableContext>
    <DragOverlay>
        {activeItem ? (
            <Panel title={activeItem.title} subtitle={activeItem.subtitle}
                   collapsible={false} headerVariant={headerVariant}
                   dragHandleProps={{}} className="shadow-2xl ring-1 ring-border">
                {activeItem.body}
            </Panel>
        ) : null}
    </DragOverlay>
</DndContext>

Reading global defaults from LayoutContext

const {config} = useLayout()
 
<Panel
    title="Revenue"
    subtitle="Last 30 days"
    headerVariant={config.panelHeaderVariant}
    fill={config.panelEqualHeight}
>
    <RevenueChart />
</Panel>

Best practices

  • Always pass a meaningful subtitle. It's required; use it for context ("Last 30 days", "Team feed"), not filler.
  • Opt into only the tools you'll wire. refreshable without onRefresh, or closable without onClose, gives a button that does nothing.
  • For equal-height grid rows, wrap the panel's grid cell in h-full and set fill. fill gives the panel h-full, but it needs a cell with a resolved height to fill.
  • Honor global panel config where it makes sense — pass config.panelHeaderVariant / config.panelEqualHeight so the Customizer's "Panels" section actually affects your panels.
  • Keep Panel DnD-agnostic. Don't add @dnd-kit imports to it — spread sortable listeners through dragHandleProps at the call site.
  • Match the header variant to intent, not decoration: skin for accent/hero panels, dark/black to echo a dark or OLED sidebar, muted for a subtle separation, default otherwise.

Troubleshooting

  • Refresh spinner never stops (dev only, React StrictMode). Panel tracks whether it's mounted with a mounted ref and only clears refreshing if (mounted.current). The subtle bug: StrictMode double-invokes effects (mount → unmount → remount) in development. If the mounted effect only set mounted.current = false on cleanup, the ref would be stuck false after the remount, so the refresh completion would skip setRefreshing(false) forever. The fix (already in the source): the effect sets mounted.current = true on mount and false on cleanup — re-setting true on the remount:

    useEffect(() => {
        mounted.current = true
        return () => {
            mounted.current = false
        }
    }, [])

    If you ever refactor this, keep both assignments — dropping the = true reintroduces the stuck-spinner bug.

  • Panels in a row aren't equal height. Set fill (or wire config.panelEqualHeight) and ensure the grid cell can stretch (h-full on the wrapper, a real grid row). fill alone in a non-stretching container does nothing.

  • The maximized overlay doesn't close on Escape / outside click. Only appears when maximizable. Dismissal is handled by useDismiss on the overlay's inner container; if you've wrapped the panel in something that stops propagation of mousedown/keydown, that can swallow it.

  • The menu is clipped inside a scrollable panel body / column. The menu is a Dropdown rendered with portal, so it escapes overflow. If you build a custom menu instead, add portal yourself (see Overlays).

  • Passing a title but no subtitle fails TypeScript. That's intentional — subtitle is required. Provide one.

  • Header looks wrong in a skin. Use headerVariant="skin" to follow the active skin's primary; hardcoding colors will fight the token system.

FAQ

  • Why is subtitle required? It enforces the project convention of no title-only panels — every panel presents a heading plus a short descriptor for scannability and consistency.
  • Does Panel depend on @dnd-kit? No. It's DnD-agnostic; it only spreads whatever you pass in dragHandleProps onto its grip handle. The demo uses @dnd-kit, but you could use any library.
  • Can I collapse a panel by default? Yes — defaultCollapsed. The collapse chevron itself is gated by collapsible (on by default).
  • Where do the global header-variant / equal-height settings live? In LayoutContext (panelHeaderVariant, panelEqualHeight), edited in the Customizer "Panels" section and Layout Settings → Appearance. Panel doesn't read them automatically — pass them from config.
  • How does maximize avoid clipping / stacking issues? It portals a second copy of the shell to document.body at z-50 over a scrim, animated with AnimatePresence.

Notes for designers & content editors

  • Every panel needs a title and a subtitle. Write subtitles as terse context, not sentences — a timeframe, a scope, a fraction ("This week", "Team feed", "64.2 GB of 100 GB used").
  • Header variant is a design lever. skin makes a panel pop in the active accent color; dark/black create a strong header that echoes the dark or OLED sidebar; muted is a quiet separation; default is neutral. The global default is set in the Customizer's "Panels" section so a whole dashboard can be re-chromed at once.
  • Equal-height tidies dashboards where panels in a row have different content lengths — turn it on in the Customizer to line them up.
  • Menu items in the overflow follow the Dropdown conventions: group actions, separate destructive ones, use tone="danger" for remove/delete.
  • Panels re-color for every skin and flip light/dark automatically — no per-skin design work.

Was this page helpful?