HomeDocsComponentsBlocksChartsThemes
Introduction
  • Introduction
  • Installation
  • Themes
  • llms.txt
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Amount
  • Aspect Ratio
  • Audio Player
  • Avatar
  • Avatar Upload
  • Badge
  • Banner
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Code Block
  • Collapsible
  • Color Picker
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Countdown
  • Data Grid
  • Data Table
  • Date Picker
  • Date Range Picker
  • Description List
  • Device Mockup
  • Dialog
  • Direction
  • Dot Loader
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • File Upload
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kanban
  • Kbd
  • Label
  • Menubar
  • Meter
  • Multi Select
  • Native Select
  • Navigation Menu
  • Number Field
  • Pagination
  • Password Strength
  • Phone Input
  • Popover
  • Progress
  • Progress Circle
  • Radio Group
  • Rating
  • Resizable
  • Responsive Dialog
  • Rich Text Editor
  • Scroll Area
  • Segmented Control
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature
  • Skeleton
  • Slider
  • Sortable
  • Spinner
  • Stat
  • Stepper
  • Switch
  • Table
  • Tabs
  • Tag Input
  • Terminal
  • Textarea
  • Time Field
  • Timeline
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Tree
  • Typography
  • Video Player
  • Window Mockup
AI
  • Message Scroller
  • Message
  • Bubble
  • Marker
  • Attachment
  • Questionnaire
  • Composer
  • Reasoning
  • Tool Call
  • Approval Card
  • Citations
Blocks
  • Airtable
  • Airtable Kanban
  • ClickUp List
  • ClickUp Kanban
  • Repeatable Group
  • Command Palette
  • Sign In and Sign Up
  • Availability Scheduler
  • AI Chat
  • Action Bar
  • Notion
  • Banking
  • Lineup Builder
  • Sports Score
  • Strava
  • Sheets
  • Notices
  • Settings
  • Billing
Utilities
  • motion
  • sounds
  • scroll-fade
  • shimmer
Another UI. Built on shadcn/ui with Phosphor icons.

Airtable Kanban

PreviousNext

An Airtable style Kanban board with smooth drag and drop for cards and stacks, built on the Kanban primitive and other Another UI primitives.

/ Accounts
Kanban
Lead
Amber ScoopCategoriesGelatoB2BDistributorMidland Dairy LinesOwnerLOLiam OrtizRevenueEmpty
Baltic BerryCategoriesDairy-freeB2CDistributorNordic Cold ChainOwnerAPAva PatelRevenueEmpty
Cape Vanilla Co.CategoriesImportsWholesaleDistributorSahara FrostOwnerLOLiam OrtizRevenueEmpty
Dune Date IcesCategoriesSorbetLocalDistributorSahara FrostOwnerMCMaya ChenRevenueEmpty
4 records
Active
Alpine ChurnCategoriesB2BWholesaleDistributorNordic Cold ChainOwnerMCMaya ChenRevenue$48,200
Andes Snow CreameryCategoriesGelatoCateringDistributorAndean FreightOwnerAPAva PatelRevenue$31,750
Aurora ScoopsCategoriesGelatoSeasonalDistributorNordic Cold ChainOwnerMCMaya ChenRevenue$22,900
Black Sesame SocialCategoriesVeganCafeDistributorPacific ChillOwnerLOLiam OrtizRevenue$18,650
Boreal BatchCategoriesLocalSeasonalDistributorAndean FreightOwnerMCMaya ChenRevenue$27,100
Cedar SpoonCategoriesCafeLocalDistributorSahara FrostOwnerNKNoah KimRevenue$20,450
Coral ConeCategoriesGelatoB2CDistributorMidland Dairy LinesOwnerAPAva PatelRevenue$16,900
Ember KulfiCategoriesDairy-freeCateringDistributorPacific ChillOwnerLOLiam OrtizRevenue$24,300
8 records
Paused
Apricot AtlasCategoriesSorbetImportsDistributorSahara FrostOwnerNKNoah KimRevenue$12,400
Cacao NorteCategoriesB2BWholesaleDistributorAndean FreightOwnerAPAva PatelRevenue$15,800
2 records
Churned
Blue Fig GelatoCategoriesGelatoCafeDistributorMidland Dairy LinesOwnerNKNoah KimRevenue$9,300
Fjord FrostCategoriesSeasonalB2BDistributorNordic Cold ChainOwnerNKNoah KimRevenue$6,100
2 records

Installation#

pnpm dlx shadcn@latest add https://anotherui.dev/r/airtable-kanban.json

Features#

  • Drag and drop. Drag cards within a stack to reorder them, or to another stack to change their value. Drag a stack header to reorder stacks. Cards make room as you move, and the drop settles into place.
  • Touch and keyboard. On phones, press and hold a card to pick it up, so the board still scrolls. With a keyboard, focus a card, press Space to pick it up, use the arrow keys, and press Space again to drop.
  • Stacks. Collapse a stack into a narrow column, rename it, add a record to it, or create a new stack.
  • One toolbar. Stacked by, Customize cards, Filter, Sort, Color and Search all sit in one row.
  • Cards. Choose which fields show on cards, and color the left edge of each card by a single select field.
  • Records. Click a card to open every field in a side panel.
  • Sorted stacks. When a sort is on, dragging a card keeps the order you see and switches the stack to a manual order.
  • Saved. This demo saves to your browser. Use Reset board in the base name menu to start over.

Usage#

The board reads the same tables as the Airtable block.

import { AirtableKanban } from "@/components/blocks/airtable-kanban"
 
export function Pipeline() {
  return (
    <AirtableKanban
      name="Sales"
      tableId="accounts"
      stackBy="status"
      colorBy="status"
      storageKey="sales-pipeline"
      onChange={(board) => console.log(board.items)}
    />
  )
}

Props#

PropTypeDefault
namestring"Gelato Distribution"
tablesTableDef[]Sample base
tableIdstringFirst table
stackBystringFirst single select field
colorBystring | nullThe stack field
onChange(board) => void
storageKeystringNot saved
accentCSS colorTheme primary
accentForegroundCSS colorTheme primary foreground
classNamestring

Built with#

The Kanban primitive does all the dragging: stacks are Kanban columns, cards are Kanban items, and the board only keeps record values in step when a card lands in a new stack. The rest is Card, Button, Input, Input Group, Item, Empty, Switch, Popover, Dropdown Menu, Badge, Avatar, Calendar, Select, Command and Sheet from Another UI. It shares its field types, cards and record panel with the Airtable block.

Code#

components/blocks/airtable-kanban.tsx
"use client"

import * as React from "react"
import {
  ArrowCounterClockwiseIcon,
  ArrowsInLineHorizontalIcon,
  ArrowsOutLineHorizontalIcon,
  CaretDownIcon,
  DatabaseIcon,
  KanbanIcon,
  MagnifyingGlassIcon,
  PaintBucketIcon,
  PlusIcon,
  ShareNetworkIcon,
  SlidersHorizontalIcon,
  StackIcon,
} from "@phosphor-icons/react"
import { cn } from "cn"

import {
  CellDisplay,
  FieldIcon,
  FilterBuilder,
  getValue,
  isComplete,
  isEmpty,
  makeView,
  matches,
  MenuChoice,
  MenuChoices,
  compare,
  displayText,
  Pill,
  pillColor,
  primaryText,
  RecordSheet,
  SAMPLE_TABLES,
  SortMenu,
  ToolbarButton,
  TINT,
  tint,
  uid,
  visibleFields,
  type CellValue,
  type FieldDef,
  type Row,
  type TableDef,
  type ViewDef,
} from "@/components/blocks/airtable"
import {
  Kanban,
  KanbanBoard,
  KanbanColumn,
  KanbanColumnContent,
  KanbanColumnHandle,
  KanbanColumnHeader,
  KanbanItem,
  KanbanOverlay,
  type KanbanDragEnd,
  type KanbanDragStart,
  type KanbanValue,
} from "@/components/ui/kanban"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Empty, EmptyDescription } from "@/components/ui/empty"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Item } from "@/components/ui/item"
import { Switch } from "@/components/ui/switch"

/* Board state */

// Stack keys are option names. "" holds records with no value.
type Items = { [stack: string]: string[] }

type BoardState = {
  tables: TableDef[]
  view: ViewDef
  stackBy: string
  colorBy: string | null
  items: Items
  stackOrder: string[]
  collapsed: string[]
}

export type AirtableKanbanProps = {
  /** Base name shown in the header. */
  name?: string
  /** Tables in the base. Linked record fields can point between them. */
  tables?: TableDef[]
  /** The table shown on the board. Defaults to the first table. */
  tableId?: string
  /** Single select field that makes the stacks. Defaults to the first one. */
  stackBy?: string
  /** Single select field that colors the left edge of each card. */
  colorBy?: string | null
  /** Called with the board after every change. */
  onChange?: (state: BoardState) => void
  /** Saves the board to localStorage under this key. */
  storageKey?: string
  /** Header color. Defaults to the theme's primary color. */
  accent?: string
  accentForeground?: string
  className?: string
}

function stackField(table: TableDef, id: string) {
  return table.fields.find((f) => f.id === id && f.type === "singleSelect")
}

function stackKeys(field: FieldDef) {
  return ["", ...(field.options ?? [])]
}

// Puts each record in the stack that matches its value, keeping the order
// the board already has and adding new records to the bottom.
function reconcile(items: Items, rows: Row[], field: FieldDef, keys: string[]): Items {
  const valueOf = new Map(rows.map((r) => [r.id, String(r.cells[field.id] ?? "")]))
  const next: Items = {}
  const placed = new Set<string>()
  for (const key of keys) {
    next[key] = (items[key] ?? []).filter((id) => valueOf.get(id) === key && !placed.has(id))
    next[key].forEach((id) => placed.add(id))
  }
  for (const r of rows) {
    if (placed.has(r.id)) continue
    const key = valueOf.get(r.id) ?? ""
    ;(next[key] ??= []).push(r.id)
  }
  return next
}

function sameItems(a: Items, b: Items) {
  const ka = Object.keys(a)
  return ka.length === Object.keys(b).length && ka.every((k) => a[k]?.join() === b[k]?.join())
}

function initialBoard(tables: TableDef[], tableId?: string, stackBy?: string, colorBy?: string | null): BoardState {
  const table = tables.find((t) => t.id === tableId) ?? tables[0]
  const field = (stackBy && stackField(table, stackBy)) || table.fields.find((f) => f.type === "singleSelect")
  const keys = field ? stackKeys(field) : [""]
  const hidden = table.fields.filter((f) => f.type === "date" || f.id === "city").map((f) => f.id)
  return {
    tables,
    view: makeView(table, `${table.id}-kanban`, "Kanban", "kanban", { hidden }),
    stackBy: field?.id ?? "",
    colorBy: colorBy === undefined ? (field?.id ?? null) : colorBy,
    items: field ? reconcile({}, table.rows, field, keys) : { "": table.rows.map((r) => r.id) },
    stackOrder: keys,
    collapsed: [],
  }
}

function readSaved(key: string): BoardState | null {
  try {
    const saved = JSON.parse(window.localStorage.getItem(key) ?? "null")
    return saved && Array.isArray(saved.tables) && saved.view && saved.items ? saved : null
  } catch {
    return null
  }
}

/* Cards */

type CardProps = {
  tables: TableDef[]
  table: TableDef
  row: Row
  fields: FieldDef[]
  color?: string
  overlay?: boolean
}

const RecordCard = React.memo(function RecordCard({ tables, table, row, fields, color, overlay }: CardProps) {
  return (
    <Card
      size="sm"
      className={cn(
        "relative gap-2.5 px-3 text-[13px] select-none",
        color && "pl-4",
        overlay && "rotate-2 cursor-grabbing shadow-xl ring-2 ring-sky-500/40"
      )}
    >
      {color && <span className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />}
      <span className="font-medium">{primaryText(table, row) || "Unnamed record"}</span>
      {fields.map((f) => {
        const value = getValue(tables, row, f)
        return (
          <span key={f.id} className="flex flex-col gap-1">
            <span className="flex items-center gap-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
              <FieldIcon type={f.type} className="size-3" />
              {f.name}
            </span>
            <span className="flex min-h-5 min-w-0 items-center">
              {isEmpty(value) ? (
                <span className="text-muted-foreground">Empty</span>
              ) : (
                <CellDisplay tables={tables} field={f} value={value} wrap />
              )}
            </span>
          </span>
        )
      })}
    </Card>
  )
})

/* Stacks */

type StackProps = {
  stack: string
  field: FieldDef
  ids: string[]
  total: number
  collapsed: boolean
  children: React.ReactNode
  onToggle: () => void
  onAdd: () => void
  onRename: (name: string) => void
}

function Stack({ stack, field, ids, total, collapsed, children, onToggle, onAdd, onRename }: StackProps) {
  const [renaming, setRenaming] = React.useState(false)
  // Only a stack the user just collapsed or expanded plays the swap.
  const [swapped, setSwapped] = React.useState(false)
  const toggle = () => {
    setSwapped(true)
    onToggle()
  }
  const label = stack ? (
    <Pill color={pillColor(field, stack)}>{stack}</Pill>
  ) : (
    <span className="font-medium text-muted-foreground">Uncategorized</span>
  )

  // One column for both states, so its width tweens (transitions.dev card
  // resize). Uncategorized stays first, but cards can still be dropped on it.
  return (
    <KanbanColumn
      value={stack}
      disabled={stack === ""}
      collapsed={collapsed}
      className={cn(
        "gap-0 overflow-hidden rounded-[calc(var(--ui-radius)+0.5rem)] bg-muted/70 p-0 data-over:ring-0",
        collapsed ? "min-h-64" : "max-h-full"
      )}
    >
      {collapsed ? (
        <KanbanColumnHandle asChild>
          <Button
            variant="ghost"
            aria-label={`Expand ${stack || "Uncategorized"}`}
            onClick={toggle}
            className={cn("h-auto w-11 flex-1 flex-col justify-start gap-3 rounded-[calc(var(--ui-radius)+0.5rem)] px-0 py-3", swapped && "t-swap-in")}
          >
            {stack ? (
              <Badge
                variant="secondary"
                style={tint(pillColor(field, stack))}
                // In vertical writing, px pads the long side and py the short one.
                className={cn("h-auto rounded-full border-0 px-2 py-1 [writing-mode:vertical-rl]", TINT)}
              >
                {stack}
              </Badge>
            ) : (
              <span className="text-xs font-medium text-muted-foreground [writing-mode:vertical-rl]">Uncategorized</span>
            )}
            <span className="text-xs text-muted-foreground tabular-nums [writing-mode:vertical-rl]">
              {total} {total === 1 ? "record" : "records"}
            </span>
          </Button>
        </KanbanColumnHandle>
      ) : (
        <div className={cn("flex min-h-0 w-[min(17rem,82vw)] flex-1 flex-col", swapped && "t-swap-in")}>
          <KanbanColumnHandle asChild>
            <KanbanColumnHeader className="px-3 pt-2.5 pb-1.5">
              {renaming ? (
                <form
                  className="flex-1"
                  onSubmit={(e) => {
                    e.preventDefault()
                    const value = String(new FormData(e.currentTarget).get("name") ?? "").trim()
                    if (value) onRename(value)
                    setRenaming(false)
                  }}
                >
                  <Input
                    name="name"
                    autoFocus
                    defaultValue={stack}
                    className="h-7"
                    onBlur={(e) => e.currentTarget.form?.requestSubmit()}
                    onKeyDown={(e) => {
                      e.stopPropagation()
                      if (e.key === "Escape") setRenaming(false)
                    }}
                  />
                </form>
              ) : (
                <span className="flex min-w-0 items-center">{label}</span>
              )}
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-xs"
                    aria-label="Stack options"
                    className="ml-auto"
                    onPointerDown={(e) => e.stopPropagation()}
                  >
                    <CaretDownIcon weight="bold" />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem onSelect={onAdd}>
                    <PlusIcon weight="bold" />
                    Add record
                  </DropdownMenuItem>
                  {stack && (
                    <DropdownMenuItem onSelect={() => setRenaming(true)}>
                      <StackIcon weight="duotone" />
                      Rename stack
                    </DropdownMenuItem>
                  )}
                  <DropdownMenuItem onSelect={toggle}>
                    <ArrowsInLineHorizontalIcon weight="bold" />
                    Collapse stack
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </KanbanColumnHeader>
          </KanbanColumnHandle>
          <ScrollFadeArea fade="y">
            <KanbanColumnContent className="min-h-16 px-2 pt-1 pb-2">
              {children}
              {ids.length === 0 && (
                <Empty className="gap-0 p-0 py-5">
                  <EmptyDescription className="text-xs">No records</EmptyDescription>
                </Empty>
              )}
            </KanbanColumnContent>
          </ScrollFadeArea>
          <div className="grid grid-cols-3 items-center px-3 pb-2.5">
            <span className="text-xs text-muted-foreground tabular-nums">
              {total} {total === 1 ? "record" : "records"}
            </span>
            <Button
              type="button"
              size="icon-sm"
              aria-label={`Add record to ${stack || "Uncategorized"}`}
              className="mx-auto rounded-full"
              onClick={onAdd}
            >
              <PlusIcon weight="bold" />
            </Button>
            <Button
              type="button"
              variant="ghost"
              size="icon-xs"
              aria-label="Collapse stack"
              className="ml-auto text-muted-foreground"
              onClick={toggle}
            >
              <ArrowsInLineHorizontalIcon weight="bold" />
            </Button>
          </div>
        </div>
      )}
    </KanbanColumn>
  )
}

function NewStack({ onAdd }: { onAdd: (name: string) => void }) {
  const [open, setOpen] = React.useState(false)
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button type="button" variant="ghost" className="h-11 w-56 shrink-0 self-start rounded-[calc(var(--ui-radius)+0.5rem)] bg-muted/70 font-normal">
          <PlusIcon weight="bold" />
          New stack
        </Button>
      </PopoverTrigger>
      <PopoverContent align="start" className="w-64 p-3">
        <form
          className="flex flex-col gap-2"
          onSubmit={(e) => {
            e.preventDefault()
            const value = String(new FormData(e.currentTarget).get("name") ?? "").trim()
            if (value) onAdd(value)
            setOpen(false)
          }}
        >
          <Input name="name" autoFocus placeholder="Stack name" />
          <Button type="submit" size="sm" className="self-end">
            Create stack
          </Button>
        </form>
      </PopoverContent>
    </Popover>
  )
}

/* The board */

export function AirtableKanban({
  name = "Gelato Distribution",
  tables: initialTables = SAMPLE_TABLES,
  tableId,
  stackBy,
  colorBy,
  onChange,
  storageKey,
  accent,
  accentForeground,
  className,
}: AirtableKanbanProps) {
  const initial = React.useMemo(
    () => initialBoard(initialTables, tableId, stackBy, colorBy),
    [initialTables, tableId, stackBy, colorBy]
  )
  const [board, setBoard] = React.useState(initial)
  const [ready, setReady] = React.useState(!storageKey)
  const [query, setQuery] = React.useState("")
  const [expanded, setExpanded] = React.useState<string | null>(null)
  const [active, setActive] = React.useState<{ id: string; type: "card" | "stack" } | null>(null)
  const changed = React.useRef(false)
  const onChangeRef = React.useRef(onChange)
  onChangeRef.current = onChange

  const { tables, view, items, stackOrder, collapsed } = board
  const table = tables.find((t) => t.id === view.tableId) ?? tables[0]
  const field = stackField(table, board.stackBy)
  const colorField = board.colorBy ? stackField(table, board.colorBy) : undefined
  const rowsById = React.useMemo(() => new Map(table.rows.map((r) => [r.id, r])), [table.rows])
  const cardFields = visibleFields(table, view)
    .slice(1)
    .filter((f) => f.id !== field?.id)

  React.useEffect(() => {
    if (!storageKey) return
    const saved = readSaved(storageKey)
    if (saved) setBoard(saved)
    setReady(true)
  }, [storageKey])

  React.useEffect(() => {
    if (!ready) return
    if (storageKey) {
      try {
        window.localStorage.setItem(storageKey, JSON.stringify(board))
      } catch {
        // Storage can be full or blocked. The board still works without it.
      }
    }
    if (changed.current) onChangeRef.current?.(board)
    changed.current = true
  }, [board, storageKey, ready])

  // Keep stacks in step with record values, except mid drag, when a card is
  // allowed to sit in a stack its value does not match yet.
  React.useEffect(() => {
    if (active || !field) return
    const keys = [...stackOrder.filter((k) => k === "" || field.options?.includes(k)), ...(field.options ?? []).filter((o) => !stackOrder.includes(o))]
    const next = reconcile(items, table.rows, field, keys)
    if (!sameItems(next, items) || keys.join() !== stackOrder.join())
      setBoard((b) => ({ ...b, items: next, stackOrder: keys }))
  }, [active, field, items, stackOrder, table.rows])

  const patch = (p: Partial<BoardState> | ((b: BoardState) => Partial<BoardState>)) =>
    setBoard((b) => ({ ...b, ...(typeof p === "function" ? p(b) : p) }))

  const updateTable = (fn: (t: TableDef) => TableDef) =>
    patch((b) => ({ tables: b.tables.map((t) => (t.id === table.id ? fn(t) : t)) }))

  const setCell = (rowId: string, fieldId: string, v: CellValue) =>
    updateTable((t) => ({
      ...t,
      rows: t.rows.map((r) => (r.id === rowId ? { ...r, cells: { ...r.cells, [fieldId]: v } } : r)),
    }))

  const updateView = (p: Partial<ViewDef> | ((v: ViewDef) => Partial<ViewDef>)) =>
    patch((b) => ({ view: { ...b.view, ...(typeof p === "function" ? p(b.view) : p) } }))

  const addRecord = (stack: string) => {
    if (!field) return
    const id = `${table.id}-${uid()}`
    updateTable((t) => ({ ...t, rows: [...t.rows, { id, cells: { [field.id]: stack || null } }] }))
    patch((b) => ({ items: { ...b.items, [stack]: [...(b.items[stack] ?? []), id] } }))
    setExpanded(id)
  }

  const deleteRecord = (id: string) => {
    updateTable((t) => ({ ...t, rows: t.rows.filter((r) => r.id !== id) }))
  }

  const setOptions = (fn: (options: string[]) => string[], rename?: [string, string]) => {
    if (!field) return
    updateTable((t) => ({
      ...t,
      fields: t.fields.map((f) => (f.id === field.id ? { ...f, options: fn(f.options ?? []) } : f)),
      rows: rename
        ? t.rows.map((r) =>
            r.cells[field.id] === rename[0] ? { ...r, cells: { ...r.cells, [field.id]: rename[1] } } : r
          )
        : t.rows,
    }))
  }

  const addStack = (name: string) => {
    if (!field || field.options?.includes(name)) return
    setOptions((o) => [...o, name])
    patch((b) => ({ stackOrder: [...b.stackOrder, name], items: { ...b.items, [name]: [] } }))
  }

  const renameStack = (from: string, to: string) => {
    if (!field || from === to || field.options?.includes(to)) return
    setOptions((o) => o.map((x) => (x === from ? to : x)), [from, to])
    patch((b) => {
      const { [from]: moved = [], ...rest } = b.items
      return {
        stackOrder: b.stackOrder.map((k) => (k === from ? to : k)),
        items: { ...rest, [to]: moved },
        collapsed: b.collapsed.map((k) => (k === from ? to : k)),
      }
    })
  }

  const changeStackBy = (id: string) => {
    const next = initialBoard(tables, table.id, id, board.colorBy)
    patch({ stackBy: id, items: next.items, stackOrder: next.stackOrder, collapsed: [] })
  }

  const reset = () => {
    if (storageKey) {
      try {
        window.localStorage.removeItem(storageKey)
      } catch {}
    }
    setBoard(initial)
    setExpanded(null)
  }

  /* What each stack shows: filters, search and sorts on top of the board order */

  const q = query.trim().toLowerCase()
  const conds = view.filters.filter(isComplete)
  const shows = (r: Row) =>
    (!conds.length ||
      (view.conjunction === "and"
        ? conds.every((c) => matches(tables, table, r, c))
        : conds.some((c) => matches(tables, table, r, c)))) &&
    (!q || table.fields.some((f) => displayText(tables, f, getValue(tables, r, f)).toLowerCase().includes(q)))

  const visibleIds = (stack: string) => {
    const rows = (items[stack] ?? []).map((id) => rowsById.get(id)).filter((r): r is Row => !!r && shows(r))
    if (view.sorts.length && !active)
      rows.sort((a, b) => {
        for (const s of view.sorts) {
          const f = table.fields.find((x) => x.id === s.field)
          const c = f ? compare(tables, f, a, b) : 0
          if (c) return s.dir === "asc" ? c : -c
        }
        return 0
      })
    return rows.map((r) => r.id)
  }

  /* Drag and drop. The Kanban primitive moves the ids, the board keeps values in step. */

  const sortedAll = (stack: string, ids: string[]) => {
    const shown = visibleIds(stack)
    return [...shown, ...ids.filter((id) => !shown.includes(id))]
  }

  // The board shows the records that pass filters and search. Hidden records
  // keep their place at the end of their stack.
  const boardValue: KanbanValue<string> = Object.fromEntries(stackOrder.map((k) => [k, visibleIds(k)]))

  const onBoardChange = (next: KanbanValue<string>) =>
    patch((b) => {
      const placed = new Set(Object.values(next).flat())
      return {
        items: Object.fromEntries(
          Object.keys(next).map((k) => [k, [...next[k], ...(b.items[k] ?? []).filter((id) => !placed.has(id))]])
        ),
        stackOrder: Object.keys(next),
      }
    })

  const onDragStart = (start: KanbanDragStart) => {
    // Dragging keeps what you see: a sorted stack becomes the manual order.
    if (start.type === "item" && view.sorts.length) {
      patch((b) => ({
        items: Object.fromEntries(Object.keys(b.items).map((k) => [k, sortedAll(k, b.items[k])])),
        view: { ...b.view, sorts: [] },
      }))
    }
    setActive({ id: start.id, type: start.type === "column" ? "stack" : "card" })
  }

  // A card dropped in another stack takes that stack's value.
  const onDragEnd = (end: KanbanDragEnd<string>) => {
    setActive(null)
    if (end.type === "item" && field && end.to !== end.from) setCell(end.id, field.id, end.to || null)
  }

  const cardColor = (r: Row) =>
    colorField && !isEmpty(r.cells[colorField.id]) ? pillColor(colorField, String(r.cells[colorField.id])) : undefined
  // Uncategorized always shows, like Airtable, so the board never shifts mid drag.
  const shown = stackOrder
  const order = shown.flatMap((k) => (collapsed.includes(k) ? [] : visibleIds(k)))
  const selects = table.fields.filter((f) => f.type === "singleSelect")

  return (
    <Card
      className={cn("w-full gap-0 py-0 text-sm", className)}
      style={
        {
          "--at-accent": accent ?? "var(--primary)",
          "--at-accent-fg": accentForeground ?? (accent ? "white" : "var(--primary-foreground)"),
        } as React.CSSProperties
      }
    >
      {/* Base header */}
      <div className="flex items-center gap-2 bg-(--at-accent) px-3 py-2 text-(--at-accent-fg)">
        <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[color-mix(in_oklab,var(--at-accent-fg)_15%,transparent)]">
          <DatabaseIcon weight="duotone" className="size-4" />
        </span>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              type="button"
              variant="ghost"
              size="sm"
              className="min-w-0 font-semibold"
              style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}
            >
              <span className="truncate">{name}</span>
              <CaretDownIcon weight="bold" className="opacity-70" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start" className="w-56">
            <DropdownMenuLabel>{name}</DropdownMenuLabel>
            <DropdownMenuItem onSelect={reset}>
              <ArrowCounterClockwiseIcon weight="bold" />
              Reset board
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
        <span className="hidden truncate opacity-70 sm:inline">/ {table.name}</span>
        <div className="ml-auto" style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}>
          <Button type="button" size="sm" variant="secondary">
            <ShareNetworkIcon weight="duotone" />
            <span className="hidden sm:inline">Share view</span>
          </Button>
        </div>
      </div>

      {/* Every board control lives in this one row */}
      <div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1.5 [scrollbar-width:none]">
        <span className="flex shrink-0 items-center gap-1.5 px-2 font-medium">
          <KanbanIcon weight="duotone" className="size-4 text-emerald-600 dark:text-emerald-400" />
          {view.name}
        </span>
        <div className="mx-1 h-5 w-px shrink-0 bg-border" />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <ToolbarButton icon={StackIcon} label={field ? `Stacked by ${field.name}` : "Stack by"} tint="" />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start">
            <DropdownMenuLabel>Stack records by</DropdownMenuLabel>
            <MenuChoices value={board.stackBy} onValueChange={changeStackBy}>
              {selects.map((f) => (
                <MenuChoice key={f.id} value={f.id}>
                  {f.name}
                </MenuChoice>
              ))}
            </MenuChoices>
          </DropdownMenuContent>
        </DropdownMenu>
        <Popover>
          <PopoverTrigger asChild>
            <ToolbarButton icon={SlidersHorizontalIcon} weight="bold" label="Customize cards" tint="" />
          </PopoverTrigger>
          <PopoverContent align="start" className="w-64 p-2">
            <p className="px-2 py-1 text-xs font-medium text-muted-foreground">Fields on cards</p>
            {table.fields
              .slice(1)
              .filter((f) => f.id !== field?.id)
              .map((f) => (
                <Item key={f.id} asChild size="xs" className="flex-nowrap rounded-md border-0 px-2 py-1.5 hover:bg-muted">
                  <label>
                    <Switch
                      checked={!view.hidden.includes(f.id)}
                      onCheckedChange={(on) =>
                        updateView((v) => ({ hidden: on ? v.hidden.filter((h) => h !== f.id) : [...v.hidden, f.id] }))
                      }
                    />
                    <FieldIcon type={f.type} className="text-muted-foreground" />
                    <span className="truncate">{f.name}</span>
                  </label>
                </Item>
              ))}
          </PopoverContent>
        </Popover>
        <FilterBuilder tables={tables} table={table} view={view} update={updateView} />
        <SortMenu table={table} view={view} update={updateView} />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <ToolbarButton
              icon={PaintBucketIcon}
              label="Color"
              active={!!colorField}
              tint="bg-amber-500/15 text-amber-900 hover:bg-amber-500/20 dark:text-amber-100"
            />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start">
            <DropdownMenuLabel>Color cards by</DropdownMenuLabel>
            <MenuChoices
              value={board.colorBy ?? "none"}
              onValueChange={(v) => patch({ colorBy: v === "none" ? null : v })}
            >
              <MenuChoice value="none">No color</MenuChoice>
              {selects.map((f) => (
                <MenuChoice key={f.id} value={f.id}>
                  {f.name}
                </MenuChoice>
              ))}
            </MenuChoices>
            {collapsed.length > 0 && (
              <>
                <DropdownMenuSeparator />
                <DropdownMenuItem onSelect={() => patch({ collapsed: [] })}>
                  <ArrowsOutLineHorizontalIcon weight="bold" />
                  Expand all stacks
                </DropdownMenuItem>
              </>
            )}
          </DropdownMenuContent>
        </DropdownMenu>
        <div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
          <InputGroup className="w-32 lg:w-48">
            <InputGroupAddon>
              <MagnifyingGlassIcon weight="duotone" />
            </InputGroupAddon>
            <InputGroupInput placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
          </InputGroup>
        </div>
      </div>

      {field ? (
        <Kanban
          value={boardValue}
          onValueChange={onBoardChange}
          onDragStart={onDragStart}
          onDragEnd={onDragEnd}
          onDragCancel={() => setActive(null)}
        >
          <KanbanBoard className="h-[36rem] bg-muted/30 p-3">
            {shown.map((stack) => {
              const ids = boardValue[stack]
              return (
                <Stack
                  key={stack || "uncategorized"}
                  stack={stack}
                  field={field}
                  ids={ids}
                  total={ids.length}
                  // An empty Uncategorized stack stays narrow, even while a card is over it.
                  collapsed={
                    (collapsed.includes(stack) ||
                      (stack === "" && !(items[""] ?? []).some((id) => id !== active?.id))) &&
                    !(active?.type === "stack" && active.id === stack)
                  }
                  onToggle={() =>
                    patch((b) => ({
                      collapsed: b.collapsed.includes(stack)
                        ? b.collapsed.filter((k) => k !== stack)
                        : [...b.collapsed, stack],
                    }))
                  }
                  onAdd={() => addRecord(stack)}
                  onRename={(to) => renameStack(stack, to)}
                >
                  {ids.map((id) => (
                    <KanbanItem
                      key={id}
                      value={id}
                      asHandle
                      className="focus-visible:ring-sky-500"
                      onClick={() => setExpanded(id)}
                      onKeyDown={(e) => e.key === "Enter" && setExpanded(id)}
                    >
                      <RecordCard
                        tables={tables}
                        table={table}
                        row={rowsById.get(id)!}
                        fields={cardFields}
                        color={cardColor(rowsById.get(id)!)}
                      />
                    </KanbanItem>
                  ))}
                </Stack>
              )
            })}
            <NewStack onAdd={addStack} />
          </KanbanBoard>
          <KanbanOverlay>
            {(drag) => {
              if (drag.type === "item") {
                const row = rowsById.get(drag.id)
                return row ? (
                  <div className="w-[min(16rem,78vw)]">
                    <RecordCard tables={tables} table={table} row={row} fields={cardFields} color={cardColor(row)} overlay />
                  </div>
                ) : null
              }
              return (
                <div className="flex h-24 w-[min(17rem,82vw)] items-start rotate-1 rounded-xl bg-muted p-3 shadow-xl ring-2 ring-sky-500/40">
                  {drag.id ? <Pill color={pillColor(field, drag.id)}>{drag.id}</Pill> : null}
                  <span className="ml-auto text-xs text-muted-foreground tabular-nums">
                    {(boardValue[drag.id] ?? []).length} records
                  </span>
                </div>
              )
            }}
          </KanbanOverlay>
        </Kanban>
      ) : (
        <Empty className="p-6">
          <EmptyDescription>Add a single select field to stack records.</EmptyDescription>
        </Empty>
      )}

      <RecordSheet
        tables={tables}
        table={table}
        rowId={expanded}
        order={order}
        onNavigate={setExpanded}
        onClose={() => setExpanded(null)}
        setCell={setCell}
        onDelete={deleteRecord}
      />
    </Card>
  )
}
AirtableClickUp List

On This Page

InstallationFeaturesUsagePropsBuilt withCode