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.
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:
- The title and subtitle are both mandatory.
subtitleis a required prop — the project convention is no title-only panels. A panel always presents a heading and a short descriptor beneath it. - It is drag-and-drop-agnostic.
Panelnever imports@dnd-kit. Instead it accepts adragHandlePropsbag that you spread from a sortable hook; when present, a grip handle appears. This keeps the component reusable with any DnD library (or none).
Always pass title AND subtitle
subtitle is typed as required (subtitle: ReactNode) precisely so this can't be skipped. If a panel has
no natural descriptor, write a short contextual one ("Last 30 days", "Team feed", "64.2 GB of 100 GB
used") rather than omitting it.
Architecture & files
| File | Responsibility |
|---|---|
src/components/ui/Panel.tsx | The Panel component, its internal PanelAction icon button, the RefreshOverlay loading overlay, and the PanelHeaderVariant type. |
src/pages/ui/PanelsPage.tsx | The /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.tsx | Owns 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
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — (required) | The panel heading. |
subtitle | ReactNode | — (required) | Descriptor under the title. Required by convention — no title-only panels. |
children | ReactNode | — (required) | Body content. |
collapsible | boolean | true | Show the chevron that toggles the body via <Collapse>. |
refreshable | boolean | false | Show a refresh button that spins while onRefresh runs. |
maximizable | boolean | false | Show a maximize button that opens the panel full-screen. |
closable | boolean | false | Show a close (×) button that calls onClose. |
defaultCollapsed | boolean | false | Start collapsed. |
onRefresh | () => void | Promise<void> | — | Runs on refresh; the icon spins until it resolves. |
onClose | () => void | — | Called by the close (×) button. |
menu | ReactNode | — | DropdownItems shown under a ⋯ overflow trigger. |
dragHandleProps | Record<string, unknown> | — | dnd-kit {...attributes, ...listeners} — spread onto the grip handle. When set, a drag handle shows. Stays dnd-kit-agnostic. |
headerVariant | PanelHeaderVariant | 'default' | Header chrome — see Header variants. |
fill | boolean | false | Fill container height (h-full) so panels in a grid row match the tallest. Per-panel override of the global equal-height setting. |
className | string | — | Extra 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.
| Value | Appearance | Matches which sidebar |
|---|---|---|
default | Standard theme surface (bg-surface), no rounded/tinted header. | — (baseline) |
muted | Tinted header (bg-surface-muted), rounded top. | — |
dark | Forced-dark header — re-scopes the base dark tokens (.dark bg-surface). | The dark sidebar (darkSidebar). |
black | True-black header — adds sidebar-oled for the pure-black tokens (.dark sidebar-oled bg-surface). | The true-black / OLED sidebar (oledSidebar). |
skin | Accent header that follows the active skin — bg-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 key | Type | Default | Meaning |
|---|---|---|---|
config.panelHeaderVariant | PanelHeaderVariant | 'default' | Default header chrome for panels that read it. |
config.panelEqualHeight | boolean | false | Whether 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:
- Ignores the click if a refresh is already running (no double-fire).
- Sets
refreshing→ theRefreshCwicon tints primary and aRefreshOverlay(a centered spinner over a translucentbg-surface/70backdrop) covers the body. - 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. - Clears
refreshingonce both settle — but only if the component is still mounted (guarded by amountedref).
The RefreshOverlay reuses the minimal route-loader spinner (border-t-primary ring) so refresh feedback
matches the rest of the app.
Info
The 15 s cap is a safety ceiling for a hung onRefresh, not a normal completion path — real refreshes
resolve well before it.
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
DndContextwithPointerSensor(activationConstraint: {distance: 5}) + aKeyboardSensor(sortableKeyboardCoordinates, customkeyboardCodes). - A
SortableContextusingrectSortingStrategy(grid), withclosestCornerscollision detection. - Each panel wrapped in a
useSortablecell;{...attributes, ...listeners}are spread intodragHandleProps, andtransform/transitiononto the cell. - While a cell is dragging, it swaps the
Panelfor a dashed drop placeholder (border-2 border-dashed border-primary/50 bg-primary/5) that slides to the hovered slot. - A
DragOverlayrenders the floating panel; since the overlay isn't sortable, it passes an emptydragHandleProps={{}}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.
refreshablewithoutonRefresh, orclosablewithoutonClose, gives a button that does nothing. - For equal-height grid rows, wrap the panel's grid cell in
h-fulland setfill.fillgives the panelh-full, but it needs a cell with a resolved height to fill. - Honor global panel config where it makes sense — pass
config.panelHeaderVariant/config.panelEqualHeightso the Customizer's "Panels" section actually affects your panels. - Keep
PanelDnD-agnostic. Don't add@dnd-kitimports to it — spread sortable listeners throughdragHandlePropsat the call site. - Match the header variant to intent, not decoration:
skinfor accent/hero panels,dark/blackto echo a dark or OLED sidebar,mutedfor a subtle separation,defaultotherwise.
Troubleshooting
-
Refresh spinner never stops (dev only, React StrictMode).
Paneltracks whether it's mounted with amountedref and only clearsrefreshingif (mounted.current). The subtle bug: StrictMode double-invokes effects (mount → unmount → remount) in development. If themountedeffect only setmounted.current = falseon cleanup, the ref would be stuckfalseafter the remount, so the refresh completion would skipsetRefreshing(false)forever. The fix (already in the source): the effect setsmounted.current = trueon mount andfalseon cleanup — re-settingtrueon the remount:useEffect(() => { mounted.current = true return () => { mounted.current = false } }, [])If you ever refactor this, keep both assignments — dropping the
= truereintroduces the stuck-spinner bug. -
Panels in a row aren't equal height. Set
fill(or wireconfig.panelEqualHeight) and ensure the grid cell can stretch (h-fullon the wrapper, a real grid row).fillalone in a non-stretching container does nothing. -
The maximized overlay doesn't close on Escape / outside click. Only appears when
maximizable. Dismissal is handled byuseDismisson the overlay's inner container; if you've wrapped the panel in something that stops propagation ofmousedown/keydown, that can swallow it. -
The
⋯menu is clipped inside a scrollable panel body / column. The menu is aDropdownrendered withportal, so it escapes overflow. If you build a custom menu instead, addportalyourself (see Overlays). -
Passing a title but no subtitle fails TypeScript. That's intentional —
subtitleis 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
subtitlerequired? It enforces the project convention of no title-only panels — every panel presents a heading plus a short descriptor for scannability and consistency. - Does
Paneldepend on @dnd-kit? No. It's DnD-agnostic; it only spreads whatever you pass indragHandlePropsonto 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 bycollapsible(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.Paneldoesn't read them automatically — pass them fromconfig. - How does maximize avoid clipping / stacking issues? It portals a second copy of the shell to
document.bodyatz-50over a scrim, animated withAnimatePresence.
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.
skinmakes a panel pop in the active accent color;dark/blackcreate a strong header that echoes the dark or OLED sidebar;mutedis a quiet separation;defaultis 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 theDropdownconventions: group actions, separate destructive ones, usetone="danger"for remove/delete. - Panels re-color for every skin and flip light/dark automatically — no per-skin design work.
Related
Overlays & Disclosure
Dropdown (the ⋯ menu), Collapse, and the portaling behavior Panel reuses.
Core & Feedback
Card, Badge, Progress and the primitives shown inside demo panels.
Customizer & Settings
Where the global panelHeaderVariant / panelEqualHeight defaults are edited.
Design Skins
How the skin header variant follows the active skin's primary.
Animation & Effects
Collapse, spring and AnimatePresence, plus reduced motion.
Was this page helpful?
