Self-contained drag-and-drop board. Import `<BoardView />` and pass `columns` + a `cards` record keyed by column id. One column per project status: Queued is reorderable by drag handle, the rest are locked and carry a per-card play/pause toggle. Column dots and status badges take a raw `hex`, so ClickUp status colors drop straight in; `hideStatus` drops the card badge where it would only repeat the header, and `countGroup` rolls several columns into a single count above the board. Every column collapses to a narrow vertical rail from the caret in its header, and every card carries a right-click context menu. The board manages drag state internally and reports the next mapping via `onChange`.
npx shadcn@latest add https://sdk-components.thesqd.com/r/board-view.jsonimport {
BoardView
} from "@/components/blocks/board-view";"use client";
import * as React from "react";
import {
BoardView
} from "@/components/blocks/board-view";
const COLUMNS = [
{ id: "queued", title: "Queued", hex: "#656f7d", showHandle: true, hideStatus: true },
{ id: "received", title: "Received", hex: "#ffcf00", disableDnd: true, showPlayPause: true, hideStatus: true },
];
const INITIAL_CARDS = {
queued: [
{ id: "c1", href: "/projects/c1", title: "Stewardship brief", draftDate: "2026-03-14T09:00:00+00:00" },
{ id: "c2", href: "/projects/c2", title: "Outreach video", draftDate: "2026-03-18T09:00:00+00:00" },
],
received: [
{ id: "c5", href: "/projects/c5", title: "Easter graphics", draftDate: "2026-03-06T09:00:00+00:00" },
{ id: "c7", href: "/projects/c7", title: "Annual report cover", draftDate: "2026-03-09T09:00:00+00:00" },
],
};
export function Example() {
const [cards, setCards] = React.useState(INITIAL_CARDS);
return (
<BoardView
columns={COLUMNS}
cards={cards}
onChange={setCards}
// One rolled-up figure above the board instead of a cap per column.
countGroup={{ label: "Active projects", columnIds: ["received"], cap: 7 }}
/>
);
}BoardView
├── BoardViewColumn[] (props.columns — id, title, tone | hex, cap, showHandle, disableDnd, showPlayPause, hideStatus)
└── Cards record (props.cards — Record<columnId, BoardViewCard[]>)
└── BoardViewCard
├── title / description
├── status: { label, tone | hex }
├── draftDate / completedDate
├── href
├── image / imageAlt / images
├── assignees: AvatarPerson[]
└── paused (only meaningful in showPlayPause columns — flips Pause ↔ Play)
Column header
├── collapse caret → shrinks the column to a narrow vertical rail
├── status dot (tone | hex)
└── count pill (graded against the column cap)
Card gutter (left edge, shown when the column opts in)
├── showHandle → grip + #N position marker (renumbers live mid-drag,
│ each change replaying the transitions-dev number pop-in)
└── showPlayPause → Play / Pause toggle
Both together share one gutter, grip first.
Card body
├── cover (image, or images: one 16:9 frame above the title, object-cover.
│ Several fade through on hover, with a badge counting them; the
│ fade needs a real pointer and stops under reduced motion.)
├── title / description
├── date (footer left, takes the badge's slot when hideStatus is on)
│ "Draft Mar 14", or "Completed Jan 20" in a column marked
│ closed. timestamptz in, formatted out.
├── status badge (suppressed by the column's hideStatus)
└── owner avatars (footer right — Avatar people={…} version="v2-initials", max 3)
Height
├── Columns cap at max-h-[32rem]; past that the card list scrolls.
└── scroll-fade sits on that inner scroller, never on the card-surface root.
Toolbar (countGroup / searchable / filterable) — the grid's own band
│ (.records-shell + .card-surface + .records-toolbar), wrapping the HEADER
│ only; the columns below stay unboxed. Stacks on phones like the grid's.
├── search field → matches card titles and owner names
├── Filters popover → the filter-builder block (title / status / owner),
│ nested and/or clauses behind an active-count badge
├── while either is active, dragging is off and #N counts the real queue
└── a column left with no matches is hidden; all empty → "No projects match."
Counts
├── Each column header shows its own count, graded against its own cap.
└── props.countGroup rolls named columns into one figure above the board.
Right-click a card
├── Open project (when onCardClick is set)
├── Open in new tab (when the card has an href)
├── Copy link (when the card has an href)
├── Copy ID (always)
├── Pause / Resume (when the column sets showPlayPause)
├── Move to top/bottom (when the column is orderable)
└── Archive Project (destructive)
Drag rules
├── Reordering is committed on drop; the array is never mutated mid-drag.
├── #N markers read useSortable's own newIndex, so they show the landing
│ position while you drag rather than jumping on release.
├── Reordering is allowed within a column.
└── Cross-column moves are blocked — cards never leave their source column.This field is required
This field is required
BoardView
| Prop | Type | Default | Description |
|---|---|---|---|
columns | BoardViewColumn[] | — | Column configuration. |
cards | Record<string, BoardViewCard[]> | — | Cards keyed by column id. |
onChange | (next: Record<string, BoardViewCard[]>) => void | — | Fired with the new mapping after every drag. |
searchable | boolean | string | — | Show the toolbar's search field, matching card titles and owner names. Pass a string to override the placeholder. A column the search empties is hidden until it clears. |
filterable | boolean | false | Show the Filters popover — the [`filter-builder`](/blocks/filter-builder) block over Title, Status (the card's column) and Owner, so the board filters with the same nested and/or rules data grid v3 uses. Installed alongside the board. |
search | string | — | The search text, controlled. Pair with `onSearchChange` to drive the board from a search box of your own — it matches exactly as the built-in field does. |
defaultSearch | string | — | The search text on first render, uncontrolled. |
onSearchChange | (search: string) => void | — | Fires with the new text whenever the search changes. |
filters | FilterGroup | — | The filter tree, controlled. Feed it from a [`filter-builder`](/blocks/filter-builder) of your own, using the `filterFields` handed out by `onViewChange`. |
defaultFilters | FilterGroup | — | The filter tree on first render, uncontrolled. |
onFiltersChange | (filters: FilterGroup) => void | — | Fires with the whole tree whenever the panel changes it. |
hideHeader | boolean | false | Drop the toolbar band and keep the board. It still searches and filters; it just draws none of the controls, so you can render your own header and drive it through the props above. |
onViewChange | (view: BoardViewView) => void | — | Fires whenever the view changes, with everything the toolbar knows: the matching `cards`, `matchCount`, `total`, `groupCount`, `filtering`, and `filterFields` for a `FilterBuilder`. The read side of the header API. |
countGroup | { label: string; columnIds: string[]; cap?: number; icon?: ReactNode | false } | — | Rolls the named columns into a single count rendered above the board — e.g. one active-projects figure across every in-flight status — graded against `cap` like a column pill. `icon` is the leading glyph beside the label, the way data-grid v3 carries its table icon; it defaults to a board icon and `false` drops it. |
BoardViewColumn
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Matches a key in the cards record. |
title | string | — | Header label. |
tone | "brand" | "emerald" | "amber" | "sky" | "muted" | "fuchsia" | "rose" | — | Color of the header dot. Ignored when `hex` is set. |
hex | string | — | Raw hex (e.g. a `clickup_statuses.color` value like `#3db88b`) for the header dot. Overrides `tone`. |
cap | number | — | Soft cap; the count pill grades green/yellow/red against it. |
showHandle | boolean | false | Visible drag handle + #N position marker on each card. The marker renumbers live as you drag — not just on drop — and replays the transitions-dev number pop-in on each change. |
disableDnd | boolean | false | Lock the column — no drags out, no drops in. |
showPlayPause | boolean | false | Render a small solid Play/Pause toggle on the left of every card. Cards flip between Pause and Play based on `card.paused`. Shares the left gutter with `showHandle` when both are set. |
closed | boolean | false | Marks a terminal column. Its cards label the footer date **Completed** instead of **Draft**, and show `card.completedDate` when they have one. |
hideStatus | boolean | false | Hide the status badge on every card in this column — useful on a status-per-column board where the badge just repeats the header. The owner avatar still renders. |
BoardViewCard
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable card identifier. |
title | string | — | Card title. |
description | string | — | Optional secondary text. |
status | { label: string; tone?: StatusTone; hex?: string } | — | Status badge. `hex` (e.g. a `clickup_statuses.color` value) overrides `tone` and tints the pill background, border, text and leading dot. |
href | string | — | Link to the project. Enables the context menu's **Copy link** and **Open in new tab** entries; relative paths resolve against the current origin. |
completedDate | string | — | Completion date, same timestamptz / ISO format as `draftDate`. Shown in place of the draft date for cards in a column marked `closed`; falls back to `draftDate` when absent. |
draftDate | string | — | Draft date as a Postgres timestamptz / ISO string (`"2026-03-14T09:00:00+00:00"`), formatted to `Mar 14` for you. Formatted in UTC so server and client markup agree — the board is server-rendered, and the viewer's zone would trip a hydration mismatch. A value that doesn't parse as a date renders verbatim. Takes the status badge's slot when the column sets `hideStatus`. |
assignees | AvatarPerson[] | — | Owners shown in the footer right as an avatar group (`<Avatar people={…} version="v2-initials" max={3} />`) — one entry renders a single avatar, more overlap and collapse into a `+N` chip. `v2-initials` rather than `v2`: at 24px a drawn blobatar face is mush and the initials need to read. Initials and the brand color derive from `name`; pass `src` for a photo. |
image | string | — | A cover, in a 16:9 frame above the title, `object-cover` so a poster and a slide fill the same box. Leave it off for a card with no artwork yet: an empty frame would say there was some. |
imageAlt | string | "" | Alt text for the cover. Defaults to empty, since the title beside it already names the card and a repeated name is noise to a screen reader. |
images | string[] | — | Several covers instead of one: the concept options still in play on a project nobody has approved yet. The first shows at rest; hovering fades through the rest every 1.4s, as a crossfade rather than a swap, with a badge counting the options that fades out while they cycle. The fade runs only where a pointer can hover (touch fires a hover that never ends) and never under reduced motion, where the first cover simply stays. Takes precedence over `image`. |
paused | boolean | false | Only meaningful in `showPlayPause` columns. Controls which icon renders (Play when true, Pause otherwise). Toggled internally on click. |