Widget is a dashboard tile. It composes Card internally — same bounded surface, same border, same shadow — and adds the slots you always need on a tile but don't want to hand-roll every time:
title + actions.filters (Select, DatePicker) with a divider below.footer slot for Pagination on paginated tiles.dragHandleProps slot that wires the widget into a dnd-kit SortableGrid from @marmoui/ui — the drag handle button is rendered automatically when this prop is set.Filter and action sizing. Every input and button inside
filtersandactionsis the small variant:<SelectTrigger size="sm" />,<DatePicker className="h-8 text-sm" />,<Button size="sm" />,<IconButton size="sm" />,<ButtonGroup size="sm" />. The widget header is dense — full-size controls overflow on half-grid tiles. Filters also no longer wrap to a second row (Widget's filter row isflexwithmin-w-0 *:shrink), so usew-32/w-44width caps on eachSelectTriggerinstead of relying on intrinsic width.
Use Widget when the block is a dashboard module: a KPI scorecard, a chart, a paginated list/table, or any reorderable dashboard cell. Use raw Card when the block is a non-dashboard bounded surface — a contact summary, a file preview, a settings detail panel. See the side-by-side contrast in Card → Card vs Widget.
Widget adds zero visual difference to Card until you add slots. A bare <Widget> is just a Card with the same border and shadow — so dropping it into a dashboard grid keeps every tile aligned even if some tiles have headers and others don't.
import { Widget } from '@marmoui/ui';The same Widget covers three common dashboard-tile shapes. Each variant is a different combination of the title, actions, filters, and footer slots — there is no variant prop to memorise.
KPI scorecards are NOT a Widget variant. KPI tiles use the dedicated
KpiCardcomponent (or compose label + value + delta inside a bareWidget). They are not sortable or resizable, and they should not be dropped into the same grid as chart Widgets unless you intentionally mix KPI strips above the widget grid. Use Widget only for chart, list, table, or feed tiles.
When the content is self-describing — a single chart, a feed — and a header would just add noise. Same as KPI but with a richer body.
Omit title/filters/actions. Useful when the chart itself carries the label or the widget sits below an already-titled section.
<Widget>
<BarList data={data} header="Source" unit="Visits" />
</Widget>Title-only header. The most common shape for chart tiles that don't need filtering or a settings action.
The title sits in CardHeader. The body fills the rest of the widget.
<Widget title="Referral Sources">
<BarList data={data} header="Source" unit="Visits" />
</Widget>The dashboard-rich shape: title on the left, action icon on the right, filter row below. All header content is contained inside Widget — no need to compose CardHeader / CardContent manually.
`actions` renders to the right of the title (settings icon, menu). `filters` renders on a second row of the header with a divider underneath. Both reconfigure the widget's own content.
<Widget
title="Lists Growth"
actions={
<IconButton variant="ghost" size="sm" aria-label="Configure" icon={<MdSettings />} />
}
filters={
<>
<Select defaultValue="all">
<SelectTrigger className="w-44"><SelectValue placeholder="Select list" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All Lists</SelectItem>
<SelectItem value="customers">Customers</SelectItem>
<SelectItem value="leads">Leads</SelectItem>
</SelectContent>
</Select>
<DatePicker placeholder="Select date range" isRange triggerType="input" />
</>
}
>
<BarList data={data} header="Source" unit="Visits" />
</Widget>Drop the widget into a SortableGrid from @marmoui/ui and pass the render-callback's dragHandleProps straight into Widget. Widget renders the drag-handle button itself, wires it to dnd-kit's pointer + keyboard sensors, and animates the visual state via the isDragging flag. There is no native HTML5 drag anywhere — dnd-kit owns the gesture.
The drag handle is absolutely positioned on the left edge of the widget and hidden by default; it fades in on hover, on keyboard focus, and while the widget is dragging. Titles, filters, and actions stay aligned whether the widget is sortable or not.
Widget does not reorder itself — SortableGrid calls onReorder with the new array when a drop lands. The parent owns the list.
`SortableGrid` provides the dnd-kit context. Each Widget receives `dragHandleProps` and `isDragging` from the render callback.
<SortableGrid
items={items}
onReorder={setItems}
strategy="grid"
className="grid grid-cols-1 gap-4 md:grid-cols-2"
ariaLabel="Sortable widgets"
>
{(item, { dragHandleProps, isDragging }) => (
<Widget
title={item.title}
dragHandleProps={dragHandleProps}
dragHandleLabel={`Drag ${item.title} to reorder`}
isDragging={isDragging}
>
<BarList data={data} header="Channel" unit="Visits" />
</Widget>
)}
</SortableGrid>opacity-0). Reveals on group-hover/widget, on keyboard focus, and while isDragging is true.Pass hideDragHandle to keep the widget draggable but suppress the visible handle — typically when the entire widget is its own grab target (e.g., a touch-first dashboard where you want the whole tile to be grabbable).
| Prop | What it does |
|---|---|
dragHandleProps | dnd-kit handle props from the SortableGrid render callback. Spread automatically onto the internal handle button when present. |
dragHandleLabel | Aria label for the handle button (e.g. "Drag Top Buyers to reorder"). |
hideDragHandle | Keep the dnd-kit listeners attached but suppress the visible handle. |
isDragging | Dims the widget and adds a focus ring while it is the active drag source. Pass through from the render callback. |
isDropTarget | Highlights the widget with a primary ring when it is the active drop target. Driven by your onDragOver if you need a custom hover state. |
A small reorderable dashboard wired through SortableGrid. dnd-kit owns the gesture; the parent only stores the list.
Order lives in parent state. `SortableGrid` calls `onReorder` with the new array on drop. `isDragging` is driven by dnd-kit, not the parent.
type Item = { id: string; title: string };
function Dashboard({ initial }: { initial: Item[] }) {
const [items, setItems] = React.useState(initial);
return (
<SortableGrid
items={items}
onReorder={setItems}
strategy="grid"
className="grid grid-cols-1 gap-4 md:grid-cols-2"
ariaLabel="Dashboard widgets"
>
{(item, { dragHandleProps, isDragging }) => (
<Widget
title={item.title}
dragHandleProps={dragHandleProps}
dragHandleLabel={`Drag ${item.title} to reorder`}
isDragging={isDragging}
>
{/* widget body */}
</Widget>
)}
</SortableGrid>
);
}Pass Pagination to the footer slot for table or list widgets that span more rows than fit. The footer renders on its own row at the bottom of the Card with a top border.
Use the standard Marmo `Pagination` primitives. The parent owns the page state and slices the data.
const PAGE_SIZE = 4;
function TopCountries({ rows }) {
const [page, setPage] = React.useState(1);
const pageCount = Math.ceil(rows.length / PAGE_SIZE);
const visible = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<Widget
title="Top Countries"
footer={
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={page === 1}
onClick={() => setPage((p) => Math.max(p - 1, 1))}
/>
</PaginationItem>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
<PaginationItem key={n}>
<PaginationLink
isActive={n === page}
onClick={() => setPage(n)}
>
{n}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
disabled={page === pageCount}
onClick={() => setPage((p) => Math.min(p + 1, pageCount))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
}
>
<table className="w-full text-left text-sm tabular-nums">
{/* head + body using visible */}
</table>
</Widget>
);
}A list widget can be sortable (reorderable on the dashboard) and contain its own paginated content. The drag handle moves the whole widget; the pager only affects the rows inside. Wire the widget into a SortableGrid exactly as in the sortable example; pass Pagination to the footer slot independently.
Avatar list inside a Widget, with Pagination in the footer slot. Drop into a SortableGrid to make it reorderable.
<Widget
title="Top Buyers"
actions={<IconButton variant="ghost" size="sm" aria-label="Configure" icon={<MdSettings />} />}
footer={<Pagination>{/* …PaginationContent… */}</Pagination>}
>
<ul className="flex flex-col divide-y divide-border-secondary">
{pageRows.map((b) => (
<li key={b.name} className="flex items-center gap-3 py-2.5">
<Avatar size="sm"><AvatarFallback>{b.initials}</AvatarFallback></Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-ink-dark">{b.name}</div>
<div className="text-xs text-ink-light">{b.orders} orders</div>
</div>
<Badge variant="success">${b.ltv.toLocaleString()}</Badge>
</li>
))}
</ul>
</Widget>| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Title rendered in the header. Omit to render a headerless widget. |
filters | ReactNode | — | Filter controls (Select, DatePicker) on a second header row. |
actions | ReactNode | — | Trailing controls (IconButton, menu) to the right of the title. |
footer | ReactNode | — | Footer slot. Pass Pagination here for table widgets. |
dragHandleProps | WidgetDragHandleProps | — | dnd-kit handle props from SortableGrid / SortableItem render callback. When set, Widget renders and wires the drag handle. |
dragHandleLabel | string | "Drag to reorder widget" | Aria label for the drag handle button. |
hideDragHandle | boolean | false | Keep the dnd-kit listeners attached but hide the visible handle. |
isDragging | boolean | false | Visual flag — widget is the active drag source. Driven by SortableGrid. |
isDropTarget | boolean | false | Visual flag — widget is the active drop target. |
headerClassName | string | — | Class applied to the inner CardHeader. |
contentClassName | string | — | Class applied to the inner CardContent. |
footerClassName | string | — | Class applied to the footer wrapper. |
Widget composes Card — same surface, same border, same shadow. The difference is the job, not the look.
| You're building… | Use | Why |
|---|---|---|
| A KPI tile in a dashboard | Widget (no title) | Snaps into the grid even without a header. |
| A chart tile, possibly with filters | Widget (title + filters) | The header row + divider is built in. |
| A paginated table or list inside a tile | Widget with footer={<Pagination>...} | Pagination belongs in the footer slot, not in CardContent. |
| A reorderable dashboard cell | Widget inside SortableGrid, dragHandleProps forwarded | dnd-kit pointer + keyboard sensors, handle button auto-rendered, accessibility wired. |
| A contact / entity summary outside a dashboard | Card | Card + CardHeader + CardContent. No drag, no pager. |
| A file / media preview panel | Card | Same reason. |
| A full-page section with header + actions | PageSection + plain layout | Not a tile, not a summary. |
| A selectable option card | RadioCard / CheckboxCard | Dedicated input cards. |
The rule for LLMs and code-generation tooling: if the block lives in a dashboard grid, output Widget. If it's a bounded summary surface that does not live in a dashboard, output Card. If it's page chrome, output a plain <div> or <section>.
Card to add a drag handle. Use Widget inside SortableGrid and forward dragHandleProps — the handle, cursor, keyboard support, and accessibility are already wired.CardContent. Pass Pagination to the footer slot so the bottom border lines up with the rest of the dashboard grid.Widget that doesn't own the data. Header filters should only reconfigure this widget's content — never the rest of the page.react-dnd. dnd-kit is the standard. Use SortableGrid (or DragDropProvider for cross-container flows). See Drag and drop.SortableGrid + dnd-kit standard Widget integrates with.