Board view

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`.

Active projects7 / 7projects
Queued
7projects
Stewardship campaign brief
Draft Mar 14
AKJB
Volunteer recruitment poster
Draft Mar 18DP
Hospitality refresh icons
Draft Mar 21RS
Stewardship campaign — Q3 follow-up email sequence with merge tags
Draft Mar 27SR
Youth camp promo reel
Draft Apr 2CL
Sunday bulletin template refresh
Draft Apr 6
JBRS
Small groups signup landing page
Draft Apr 9DP
Received
2projects
Easter sermon series — graphics
Draft Mar 6
JBCLAK+1
Mid-week graphics — week 7
Draft Mar 9SR
Needs an Update
1project
Giving page hero refresh
Draft Mar 4RS
Waiting Feedback
2projects
Outreach video — first pass
Draft Feb 28
CLSR
Baptism invite cards
Draft Mar 2
AKJB
Final Files Delivered
1project
Annual report cover
Draft Feb 21RS
Closed
1project
Christmas Eve service slides
Completed Jan 20DP
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/board-view.json
Usage
Declare your columns + a cards record keyed by column id, then render the board.
TSImport
import {
  BoardView
} from "@/components/blocks/board-view";
TSExample
"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 }}
    />
  );
}
Composition
Anatomy of the board view.
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.
With search and filters
The same board wearing data-grid v3's toolbar — the count, search field and Filters button sit in the grid's own band (`.records-shell` + `.card-surface` + `.records-toolbar`, same 48px min-height, padding and tinted fill), wrapping the header only; the columns below stay unboxed. Below 640px the band stacks and the search takes the full width, exactly as the grid's does. `searchable` matches card titles and owner names; `filterable` mounts the [`filter-builder`](/blocks/filter-builder) block behind a Filters button — nested and/or clauses over Title, Status (a card's column) and Owner, behind an active-clause badge — so a board filters with the same panel and the same rules as the grid. A column the search or filters leaves with no cards is hidden while they're active — an unbroken row of empty columns buries the few that matched — and when nothing matches at all the board says so. Drag-and-drop switches off while a search or filter is active, since a drop index into a filtered list wouldn't line up with what you see, and #N markers keep counting against the unfiltered queue.
Active projects7 / 7projects
Queued
7projects
Stewardship campaign brief
Draft Mar 14
AKJB
Volunteer recruitment poster
Draft Mar 18DP
Hospitality refresh icons
Draft Mar 21RS
Stewardship campaign — Q3 follow-up email sequence with merge tags
Draft Mar 27SR
Youth camp promo reel
Draft Apr 2CL
Sunday bulletin template refresh
Draft Apr 6
JBRS
Small groups signup landing page
Draft Apr 9DP
Received
2projects
Easter sermon series — graphics
Draft Mar 6
JBCLAK+1
Mid-week graphics — week 7
Draft Mar 9SR
Needs an Update
1project
Giving page hero refresh
Draft Mar 4RS
Waiting Feedback
2projects
Outreach video — first pass
Draft Feb 28
CLSR
Baptism invite cards
Draft Mar 2
AKJB
Final Files Delivered
1project
Annual report cover
Draft Feb 21RS
Closed
1project
Christmas Eve service slides
Completed Jan 20DP
Your own header
`hideHeader` drops the toolbar band and keeps the board — and none of the behaviour goes with it. Search and filtering both take a value and hand back a change: `search` / `onSearchChange` and `filters` / `onFiltersChange`, controlled when you pass a value and internal when you don't, matching card titles and owner names and evaluating the same nested and/or rules as the built-in panel. `onViewChange` is the other half: it hands back what the toolbar knew — the matching `cards` keyed by column, `matchCount`, `total`, the `countGroup` roll-up as `groupCount`, whether anything is `filtering`, and the `filterFields` to feed a [`filter-builder`](/blocks/filter-builder) of your own — so your header shows the same counts without re-deriving them. The board keeps the rest of its rules either way: a column the filter empties still drops out, and drag-and-drop still switches off while a search or filter is active, since a drop index into a filtered list wouldn't line up with what you see.
0 projects
Queued
7projects
Stewardship campaign brief
Draft Mar 14
AKJB
Volunteer recruitment poster
Draft Mar 18DP
Hospitality refresh icons
Draft Mar 21RS
Stewardship campaign — Q3 follow-up email sequence with merge tags
Draft Mar 27SR
Youth camp promo reel
Draft Apr 2CL
Sunday bulletin template refresh
Draft Apr 6
JBRS
Small groups signup landing page
Draft Apr 9DP
Received
2projects
Easter sermon series — graphics
Draft Mar 6
JBCLAK+1
Mid-week graphics — week 7
Draft Mar 9SR
Needs an Update
1project
Giving page hero refresh
Draft Mar 4RS
Waiting Feedback
2projects
Outreach video — first pass
Draft Feb 28
CLSR
Baptism invite cards
Draft Mar 2
AKJB
Final Files Delivered
1project
Annual report cover
Draft Feb 21RS
Closed
1project
Christmas Eve service slides
Completed Jan 20DP
Board view
One column per project status — Queued, Received, Needs an Update, Waiting Feedback, Final Files Delivered, Closed — with the real `color` values from `clickup_statuses` driving each header dot via `hex`. Queued is the only orderable column: `showHandle: true` gives it drag handles and #N position markers. The rest are locked with `disableDnd: true` and carry a per-card Play/Pause toggle (`showPlayPause: true`), except Closed, where there is nothing left to pause. Every column sets `hideStatus: true`, so cards show their draft date and owner instead of a badge that would only repeat the header. `countGroup` rolls all six non-Queued columns into one **Active projects** figure above the board, capped at 7 — replacing the per-column cap. The caret in each header collapses that column to a vertical rail, and right-clicking a card opens a context menu whose entries adapt to what the column allows — open in a new tab, copy link, copy ID, pause/resume, move to top/bottom, archive.
Active projects7 / 7projects
Queued
7projects
Stewardship campaign brief
Draft Mar 14
AKJB
Volunteer recruitment poster
Draft Mar 18DP
Hospitality refresh icons
Draft Mar 21RS
Stewardship campaign — Q3 follow-up email sequence with merge tags
Draft Mar 27SR
Youth camp promo reel
Draft Apr 2CL
Sunday bulletin template refresh
Draft Apr 6
JBRS
Small groups signup landing page
Draft Apr 9DP
Received
2projects
Easter sermon series — graphics
Draft Mar 6
JBCLAK+1
Mid-week graphics — week 7
Draft Mar 9SR
Needs an Update
1project
Giving page hero refresh
Draft Mar 4RS
Waiting Feedback
2projects
Outreach video — first pass
Draft Feb 28
CLSR
Baptism invite cards
Draft Mar 2
AKJB
Final Files Delivered
1project
Annual report cover
Draft Feb 21RS
Closed
1project
Christmas Eve service slides
Completed Jan 20DP
Card covers
A card takes a cover: `image` for one, `images` for the concept options still in play. It sits in a 16:9 frame above the title, `object-cover`, so a portrait poster and a wide slide fill the same box. With several, the first shows at rest and hovering fades through the rest every 1.4s. Every option is in the DOM stacked in the one frame, so the change is a crossfade rather than a swap, and a badge in the corner counts the options and fades out while they cycle, because the pictures are the information then. The fade runs only where a pointer can hover, since touch fires a hover that never ends, and never under reduced motion, where the first cover simply stays. Nothing in Queued carries one: a project nobody has started has no artwork, and an empty frame would say otherwise, so a card without a cover is the same card as before.
Active projects7 / 7projects
Queued
7projects
Stewardship campaign brief
Draft Mar 14
AKJB
Volunteer recruitment poster
Draft Mar 18DP
Hospitality refresh icons
Draft Mar 21RS
Stewardship campaign — Q3 follow-up email sequence with merge tags
Draft Mar 27SR
Youth camp promo reel
Draft Apr 2CL
Sunday bulletin template refresh
Draft Apr 6
JBRS
Small groups signup landing page
Draft Apr 9DP
Received
2projects
3 options
Easter sermon series — graphics
Draft Mar 6
JBCLAK+1
Mid-week graphics — week 7
Draft Mar 9SR
Needs an Update
1project
2 options
Giving page hero refresh
Draft Mar 4RS
Waiting Feedback
2projects
2 options
Outreach video — first pass
Draft Feb 28
CLSR
Baptism invite cards
Draft Mar 2
AKJB
Final Files Delivered
1project
Annual report cover
Draft Feb 21RS
Closed
1project
Christmas Eve service slides
Completed Jan 20DP
API Reference
Props for the board, its columns, and individual cards.

BoardView

PropTypeDefaultDescription
columnsBoardViewColumn[]—Column configuration.
cardsRecord<string, BoardViewCard[]>—Cards keyed by column id.
onChange(next: Record<string, BoardViewCard[]>) => void—Fired with the new mapping after every drag.
searchableboolean | 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.
filterablebooleanfalseShow 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.
searchstring—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.
defaultSearchstring—The search text on first render, uncontrolled.
onSearchChange(search: string) => void—Fires with the new text whenever the search changes.
filtersFilterGroup—The filter tree, controlled. Feed it from a [`filter-builder`](/blocks/filter-builder) of your own, using the `filterFields` handed out by `onViewChange`.
defaultFiltersFilterGroup—The filter tree on first render, uncontrolled.
onFiltersChange(filters: FilterGroup) => void—Fires with the whole tree whenever the panel changes it.
hideHeaderbooleanfalseDrop 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

PropTypeDefaultDescription
idstring—Matches a key in the cards record.
titlestring—Header label.
tone"brand" | "emerald" | "amber" | "sky" | "muted" | "fuchsia" | "rose"—Color of the header dot. Ignored when `hex` is set.
hexstring—Raw hex (e.g. a `clickup_statuses.color` value like `#3db88b`) for the header dot. Overrides `tone`.
capnumber—Soft cap; the count pill grades green/yellow/red against it.
showHandlebooleanfalseVisible 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.
disableDndbooleanfalseLock the column — no drags out, no drops in.
showPlayPausebooleanfalseRender 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.
closedbooleanfalseMarks a terminal column. Its cards label the footer date **Completed** instead of **Draft**, and show `card.completedDate` when they have one.
hideStatusbooleanfalseHide 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

PropTypeDefaultDescription
idstring—Stable card identifier.
titlestring—Card title.
descriptionstring—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.
hrefstring—Link to the project. Enables the context menu's **Copy link** and **Open in new tab** entries; relative paths resolve against the current origin.
completedDatestring—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.
draftDatestring—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`.
assigneesAvatarPerson[]—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.
imagestring—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.
imageAltstring""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.
imagesstring[]—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`.
pausedbooleanfalseOnly meaningful in `showPlayPause` columns. Controls which icon renders (Play when true, Pause otherwise). Toggled internally on click.