Widget
A titled dashboard panel with its own refresh, expand, and menu controls, plus loading and error states.
A client component — it tracks its own refresh and expand state. Every icon-only control is named after the widget it acts on, so a reader tabbing a dashboard hears "Refresh Revenue by channel" rather than five buttons all called "Refresh"; the panel itself is a region labelled by its title, and titleId sets that title's id so a table or a chart inside can be named by it too (aria-labelledby). Returning a promise from onRefresh spins the icon until it settles. expandable opens a dialog holding the same children at a wider size; pass onExpand instead to take that over, for a route or a full-screen view of your own.
Install
npx shadcn@latest add @vibra/widgetNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { CopyIcon, DownloadIcon, PinIcon, TrendingUpIcon } from "lucide-react"
import { Widget } from "@/components/ui/widget"
const CHANNELS = [
{ name: "Direct", value: "$38,120", share: 45, token: "bg-chart-1" },
{ name: "Organic search", value: "$21,540", share: 26, token: "bg-chart-2" },
{ name: "Referral", value: "$14,900", share: 18, token: "bg-chart-3" },
{ name: "Paid social", value: "$9,760", share: 11, token: "bg-chart-4" },
]
function ChannelBars() {
return (
<div className="flex flex-col gap-2.5">
{CHANNELS.map((channel) => (
<div key={channel.name} className="flex items-center gap-3 text-sm">
<span className="w-28 shrink-0 truncate text-muted-foreground">{channel.name}</span>
<span className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<span
className={`block h-full rounded-full ${channel.token}`}
style={{ width: `${channel.share}%` }}
/>
</span>
<span className="w-16 shrink-0 text-right tabular-nums">{channel.value}</span>
</div>
))}
</div>
)
}
export default function WidgetDemo() {
const [refreshedAt, setRefreshedAt] = React.useState("4 minutes ago")
return (
<Widget
className="w-full"
icon={<TrendingUpIcon />}
title="Revenue by channel"
description="Last 28 days, net of refunds"
expandable
onRefresh={async () => {
await new Promise((resolve) => setTimeout(resolve, 900))
setRefreshedAt("just now")
}}
menu={[
{ label: "Download CSV", icon: <DownloadIcon />, onSelect: () => {} },
{ label: "Copy link", icon: <CopyIcon />, onSelect: () => {} },
{ label: "Pin to top", icon: <PinIcon />, onSelect: () => {}, separatorBefore: true },
]}
onRemove={() => {}}
footer={<span>Updated {refreshedAt}</span>}
>
<ChannelBars />
</Widget>
)
}Loading, error, and bare
The skeleton, the failure, the compact size, and a panel with no controls at all.
"use client"
import { ActivityIcon, GaugeIcon, UsersIcon } from "lucide-react"
import { Widget } from "@/components/ui/widget"
export default function WidgetStates() {
return (
<div className="grid w-full gap-4 sm:grid-cols-2">
<Widget icon={<GaugeIcon />} title="Latency" description="p95, last hour" loading>
<p>Never seen while loading.</p>
</Widget>
<Widget
icon={<ActivityIcon />}
title="Error rate"
description="Last hour"
error="The metrics store did not answer in time."
onRefresh={() => {}}
>
<p>Never seen while failing.</p>
</Widget>
<Widget
size="sm"
icon={<UsersIcon />}
title="Active now"
footer={<span>Updated every 30 seconds</span>}
>
<p className="text-2xl font-semibold tracking-tight tabular-nums">1,284</p>
</Widget>
<Widget title="Queue depth" description="No controls, no chrome — just a panel.">
<p className="text-2xl font-semibold tracking-tight tabular-nums">12</p>
</Widget>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | — | What the panel shows; it also names the panel's controls. |
| titleId | string | — | The title's id, so a table or a chart inside can be named by it with aria-labelledby. Without it the widget makes its own. |
| description | React.ReactNode | — | The reading of the data — the window, the unit, the filter. |
| icon | React.ReactNode | — | Sits before the title; sized to 4 unless it sets its own size. |
| actions | React.ReactNode | — | Controls of your own, placed before the built-in icon buttons. |
| menu | WidgetMenuItem[] | — | Entries for the overflow menu; each has a label and an onSelect. |
| onRefresh | () => void | Promise<void> | — | Adds the refresh button; return a promise to spin it until the data lands. |
| onRemove | () => void | — | Adds a destructive Remove entry at the end of the menu. |
| onExpand | () => void | — | Takes over the expand button; without it the widget opens its own dialog. |
| expandable | boolean | false | Adds the expand button, which opens the same content in a wide dialog. |
| loading | boolean | false | Swaps the body for a chart skeleton and marks the panel busy. |
| error | React.ReactNode | — | What went wrong; replaces the body with an error state that retries through onRefresh. |
| footer | React.ReactNode | — | A muted strip under the body — a timestamp, a source, a link out. |
| size | "sm" | "default" | "default" | sm tightens the card padding and drops the title a step. |
| contentClassName | string | — | Classes for the body, in the panel and in the expanded dialog alike. |
| WidgetMenuItem.label | React.ReactNode | — | The entry's text. |
| WidgetMenuItem.onSelect | () => void | — | Runs when the entry is chosen. |
| WidgetMenuItem.icon | React.ReactNode | — | A leading icon, rendered at size 4. |
| WidgetMenuItem.destructive | boolean | false | Colors the entry as destructive. |
| WidgetMenuItem.separatorBefore | boolean | false | Draws a divider above the entry, to break the menu into runs. |
Dependencies
Source
"use client"
import * as React from "react"
import { EllipsisIcon, Maximize2Icon, RefreshCwIcon, Trash2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ErrorState } from "@/components/ui/error-state"
import { ChartSkeleton } from "@/components/ui/loading-skeletons"
export type WidgetMenuItem = {
label: React.ReactNode
onSelect: () => void
icon?: React.ReactNode
destructive?: boolean
separatorBefore?: boolean
}
export type WidgetProps = React.ComponentProps<"div"> & {
title: React.ReactNode
/** The title's id, so a table or a chart inside can be named by it (`aria-labelledby`). */
titleId?: string
description?: React.ReactNode
/** Sits before the title; sized to 4 unless it sets its own size. */
icon?: React.ReactNode
/** Controls of your own, placed before the built-in icon buttons. */
actions?: React.ReactNode
menu?: WidgetMenuItem[]
/** Return a promise to spin the refresh icon until it settles. */
onRefresh?: () => void | Promise<void>
/** Adds a destructive Remove entry to the menu. */
onRemove?: () => void
/** Takes over the expand button; without it the widget opens its own dialog. */
onExpand?: () => void
expandable?: boolean
loading?: boolean
/** What went wrong; replaces the body with an error state. */
error?: React.ReactNode
footer?: React.ReactNode
size?: "sm" | "default"
contentClassName?: string
}
/** A titled panel on a dashboard, with its own refresh, expand, and menu controls. */
function Widget({
className,
title,
titleId: ownTitleId,
description,
icon,
actions,
menu,
onRefresh,
onRemove,
onExpand,
expandable = false,
loading = false,
error,
footer,
size = "default",
contentClassName,
children,
...props
}: WidgetProps) {
const id = React.useId()
const titleId = ownTitleId ?? `${id}-title`
const [refreshing, setRefreshing] = React.useState(false)
const [expanded, setExpanded] = React.useState(false)
// A refresh can outlive the widget — a dashboard is edited while it loads.
const alive = React.useRef(true)
React.useEffect(() => {
alive.current = true
return () => {
alive.current = false
}
}, [])
async function handleRefresh() {
const result = onRefresh?.()
if (!(result instanceof Promise)) return
setRefreshing(true)
try {
await result
} finally {
if (alive.current) setRefreshing(false)
}
}
const menuItems = menu ?? []
const hasMenu = menuItems.length > 0 || Boolean(onRemove)
const showExpand = expandable || Boolean(onExpand)
const ownsDialog = expandable && !onExpand
const hasControls = Boolean(actions) || Boolean(onRefresh) || showExpand || hasMenu
const body = loading ? (
<ChartSkeleton />
) : error ? (
<ErrorState
size="sm"
description={error}
onRetry={onRefresh ? () => void handleRefresh() : undefined}
/>
) : (
children
)
return (
<Card
role="region"
aria-labelledby={titleId}
aria-busy={loading || undefined}
data-slot="widget"
size={size}
className={cn("gap-3", className)}
{...props}
>
<CardHeader>
<div className="flex min-w-0 items-center gap-2">
{icon ? (
<span
aria-hidden="true"
className="text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"
>
{icon}
</span>
) : null}
<CardTitle id={titleId} className="min-w-0 truncate">
{title}
</CardTitle>
</div>
{description ? <CardDescription>{description}</CardDescription> : null}
{hasControls ? (
<CardAction>
<div className="flex items-center gap-0.5">
{actions}
{onRefresh ? (
// Every icon-only control is named after the widget it acts on,
// so a reader tabbing a dashboard hears "Refresh Revenue by
// channel" rather than five buttons all called "Refresh".
<Button
type="button"
variant="ghost"
size="icon-sm"
data-pending={refreshing || undefined}
disabled={refreshing}
aria-labelledby={`${id}-refresh ${titleId}`}
onClick={() => void handleRefresh()}
>
<RefreshCwIcon aria-hidden="true" className={cn(refreshing && "animate-spin motion-reduce:animate-none in-data-[motion=reduced]:animate-none")} />
<span id={`${id}-refresh`} className="sr-only">
Refresh
</span>
</Button>
) : null}
{showExpand ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-labelledby={`${id}-expand ${titleId}`}
onClick={() => (onExpand ? onExpand() : setExpanded(true))}
>
<Maximize2Icon aria-hidden="true" />
<span id={`${id}-expand`} className="sr-only">
Expand
</span>
</Button>
) : null}
{hasMenu ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-labelledby={`${id}-menu ${titleId}`}
>
<EllipsisIcon aria-hidden="true" />
<span id={`${id}-menu`} className="sr-only">
More options for
</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
{menuItems.map((item, index) => (
<React.Fragment key={index}>
{item.separatorBefore ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
variant={item.destructive ? "destructive" : "default"}
onClick={item.onSelect}
>
{item.icon}
{item.label}
</DropdownMenuItem>
</React.Fragment>
))}
{onRemove ? (
<>
{menuItems.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem variant="destructive" onClick={onRemove}>
<Trash2Icon />
Remove
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</CardAction>
) : null}
</CardHeader>
<CardContent className={cn("min-w-0", contentClassName)}>{body}</CardContent>
{footer ? <CardFooter className="text-sm text-muted-foreground">{footer}</CardFooter> : null}
{ownsDialog ? (
<Dialog open={expanded} onOpenChange={setExpanded}>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<div className={cn("min-w-0", contentClassName)}>{children}</div>
</DialogContent>
</Dialog>
) : null}
</Card>
)
}
export { Widget }