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.

ClickUp Kanban

PreviousNext

A ClickUp style board with tinted status columns, WIP limits, swimlanes and smooth drag and drop, built from Another UI primitives.

Marketing
To do
Email announcement
Content
Update key objectives
Send invites
PMM
Refreshments budget
In progress
Market research analysis
Agent
Competitor benchmarking
PMM
Brand positioning strategy
Content
Review
Messaging doc
Content
Launch video storyboard
DesignContent
Done
Social campaign
Design
Website assets
PMM
Mobile assets
Design

Installation#

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

Features#

  • Status columns. Each column takes a soft tint of its status colour, with a solid status pill, a count, a menu and an add button.
  • Cards. Title, a status pill, assignees, a due date chip (red when overdue), a priority flag, tags and a subtask count that opens a checklist. Some cards carry a cover.
  • Inline editing. Change status, assignees, due date and priority from the chips on a card.
  • Drag and drop. Cards slide out of the way as you drag, within a column or into another. Dropping into Done completes the task.
  • WIP limits. Click a column count to set a limit. At the limit the count turns amber with a warning, and going over plays Cuelume's warning cue.
  • Swimlanes. Switch to Status / Priority to split the board into a row per priority. Dragging between rows changes priority too.
  • Collapse. Columns collapse to a narrow strip with the transitions.dev card resize, and swimlanes collapse too.
  • One toolbar. Swimlanes, Filters, Me mode, Show closed, Search and Task sit in one row.
  • Task panel. Click a card to edit every field. It is the same panel as ClickUp List.
  • Phones. Columns scroll sideways, and press and hold picks up a card.
  • Saved. This demo saves to your browser.

Built with#

The Kanban primitive does all the dragging. Each status in each swimlane is a Kanban column, and a drop sets the task's status and priority. The rest is Card, Badge, Button, Button Group, Checkbox, Collapsible, Item, Alert, Popover, Dropdown Menu, Input Group, Tabs, Toggle, Sheet and Favorite Button. It shares its model, chips, pickers and task panel with ClickUp List and its drag setup with Airtable Kanban.

Code#

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

import * as React from "react"
import {
  ArrowsInLineHorizontalIcon,
  CalendarBlankIcon,
  CaretDownIcon,
  CaretRightIcon,
  CheckCircleIcon,
  DotsThreeIcon,
  FlagIcon,
  FunnelSimpleIcon,
  KanbanIcon,
  ListBulletsIcon,
  MagnifyingGlassIcon,
  MinusIcon,
  PlusIcon,
  RocketIcon,
  RowsIcon,
  TextAlignLeftIcon,
  TreeStructureIcon,
  UserIcon,
  WarningIcon,
} from "@phosphor-icons/react"
import { cn } from "cn"

import { MenuChoice, MenuChoices, tint, ToolbarButton, uid } from "@/components/blocks/airtable"
import {
  AddTaskForm,
  AssigneePicker,
  dayDiff,
  DuePicker,
  dueLabel,
  Flag,
  ME,
  People,
  PRIORITIES,
  priority,
  PriorityMenu,
  seedTasks,
  status,
  STATUSES,
  StatusIcon,
  StatusMenu,
  Tag,
  TAGS,
  TaskPanel,
  type PriorityKey,
  type StatusKey,
  type Task,
} from "@/components/blocks/clickup-list"
import { FavoriteButton } from "@/components/ui/favorite-button"
import {
  Kanban,
  KanbanBoard,
  KanbanColumn,
  KanbanColumnContent,
  KanbanColumnHeader,
  KanbanItem,
  KanbanOverlay,
  type KanbanDragEnd,
  type KanbanValue,
} from "@/components/ui/kanban"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { cue } from "@/lib/sounds"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Card, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Item } from "@/components/ui/item"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Toggle } from "@/components/ui/toggle"

/* Board model */

type Lanes = "none" | "priority"
type Items = KanbanValue<string>

// A status column, tinted by its status colour.
const COLUMN =
  "rounded-[calc(var(--ui-radius)+0.5rem)] bg-[color-mix(in_oklab,var(--tint)_9%,var(--card))] p-2 data-over:ring-sky-500/60"

// A container is one column in one swimlane.
const containerId = (lane: string, s: StatusKey) => `col:${lane}::${s}`
const parseContainer = (id: string) => {
  const [lane, s] = id.slice(4).split("::")
  return { lane, status: s as StatusKey }
}

// A couple of cards carry a cover, like images on ClickUp cards.
const COVERS: { [id: string]: string } = {
  t11: "linear-gradient(135deg, oklch(80% 0.1 330), oklch(78% 0.11 230))",
  t9: "linear-gradient(135deg, oklch(35% 0.02 260), oklch(80% 0.01 260))",
}

/* Cards */

type CardProps = {
  task: Task
  subtasks: Task[]
  open: boolean
  onToggleSubtasks: () => void
  onOpen: () => void
  update: (id: string, patch: Partial<Task>) => void
  overlay?: boolean
}

function TaskCard({ task, subtasks, open, onToggleSubtasks, onOpen, update, overlay }: CardProps) {
  const overdue = task.due && task.status !== "done" && dayDiff(task.due) < 0
  const s = status(task.status)
  const done = subtasks.filter((t) => t.status === "done").length
  // Chips inside the card keep their own clicks, so they do not open the panel.
  const stop = (e: React.SyntheticEvent) => e.stopPropagation()
  return (
    <Card
      size="sm"
      role="button"
      tabIndex={0}
      onClick={onOpen}
      onKeyDown={(e) => e.key === "Enter" && e.target === e.currentTarget && onOpen()}
      className={cn(
        "cursor-grab gap-2.5 px-3 text-[13px] select-none active:cursor-grabbing",
        COVERS[task.id] && "pt-0",
        overlay && "rotate-2 shadow-xl ring-2 ring-sky-500/40"
      )}
    >
      {COVERS[task.id] && <div className="-mx-3 h-24" style={{ background: COVERS[task.id] }} />}
      <div className="flex items-start gap-1.5">
        <CardTitle className={cn("flex-1 text-[13px] leading-snug font-medium", task.status === "done" && "text-muted-foreground")}>
          {task.name}
        </CardTitle>
        {task.description && <TextAlignLeftIcon weight="bold" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />}
      </div>
      <div className="flex flex-wrap items-center gap-1" onClick={stop} onKeyDown={stop}>
        <StatusMenu value={task.status} onChange={(v) => update(task.id, { status: v })}>
          <Button type="button" variant="ghost" size="inline" aria-label={`Status: ${s.label}`}>
            <Badge className="h-5 gap-1 rounded-md border-0 px-1.5 text-white" style={{ background: s.color }}>
              <s.icon weight={task.status === "done" ? "bold" : "duotone"} className="size-3" />
              {s.label}
            </Badge>
          </Button>
        </StatusMenu>
        <AssigneePicker value={task.assignees} onChange={(v) => update(task.id, { assignees: v })}>
          <Button type="button" variant="ghost" size="inline" aria-label="Assignees">
            <People names={task.assignees} max={3} />
          </Button>
        </AssigneePicker>
        <DuePicker value={task.due} onChange={(v) => update(task.id, { due: v })}>
          <Button
            type="button"
            variant="outline"
            size="xs"
            aria-label="Due date"
            className={cn("h-6 font-normal", overdue && "text-red-600 dark:text-red-400")}
          >
            <CalendarBlankIcon weight="duotone" />
            {task.due && dueLabel(task.due)}
          </Button>
        </DuePicker>
        <PriorityMenu value={task.priority} onChange={(v) => update(task.id, { priority: v })}>
          <Button type="button" variant="outline" size="xs" aria-label="Priority" className="h-6 font-normal">
            <Flag value={task.priority} label={task.priority !== "none"} />
          </Button>
        </PriorityMenu>
        {task.tags.map((t) => (
          <Tag key={t} value={t} />
        ))}
      </div>
      {subtasks.length > 0 && (
        <Collapsible open={open} onOpenChange={onToggleSubtasks} className="flex flex-col gap-1" onClick={stop} onKeyDown={stop}>
          <CollapsibleTrigger asChild>
            <Button type="button" variant="ghost" size="inline" className="w-fit text-xs font-normal text-muted-foreground">
              <TreeStructureIcon weight="duotone" />
              {done}/{subtasks.length} subtasks
              {open ? <CaretDownIcon weight="bold" className="size-3" /> : <CaretRightIcon weight="bold" className="size-3" />}
            </Button>
          </CollapsibleTrigger>
          <CollapsibleContent className="t-swap-in flex flex-col gap-1 pl-1">
            {subtasks.map((st) => (
              <label key={st.id} className="flex items-center gap-2 text-xs">
                <Checkbox
                  checked={st.status === "done"}
                  onCheckedChange={(on) => update(st.id, { status: on ? "done" : "todo" })}
                />
                <span className={cn("truncate", st.status === "done" && "text-muted-foreground line-through")}>
                  {st.name}
                </span>
              </label>
            ))}
          </CollapsibleContent>
        </Collapsible>
      )}
    </Card>
  )
}

/* Columns */

function WipLimit({
  count,
  limit,
  onChange,
}: {
  count: number
  limit: number | undefined
  onChange: (limit: number | undefined) => void
}) {
  const over = limit !== undefined && count >= limit
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="ghost"
          size="inline"
          aria-label="Count and limit"
          className={cn("text-xs tabular-nums", over ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")}
        >
          {over && <WarningIcon weight="duotone" className="size-3.5" />}
          {limit !== undefined ? `${count}/${limit}` : count}
        </Button>
      </PopoverTrigger>
      <PopoverContent align="start" className="flex w-64 flex-col gap-3 p-3">
        <div className="flex items-center justify-between gap-2">
          <span className="text-sm">Set limit to</span>
          <ButtonGroup>
            <Button type="button" variant="outline" size="icon-sm" aria-label="Lower limit" onClick={() => onChange(Math.max(1, (limit ?? count) - 1))}>
              <MinusIcon weight="bold" />
            </Button>
            <ButtonGroupText className="w-10 justify-center tabular-nums">{limit ?? "-"}</ButtonGroupText>
            <Button type="button" variant="outline" size="icon-sm" aria-label="Raise limit" onClick={() => onChange((limit ?? count) + 1)}>
              <PlusIcon weight="bold" />
            </Button>
          </ButtonGroup>
        </div>
        {over && (
          <Alert className="border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-100">
            <WarningIcon weight="duotone" />
            <AlertDescription className="text-inherit">
              {count > limit! ? "This column is over its limit." : "You've reached the limit."}
            </AlertDescription>
          </Alert>
        )}
        {limit !== undefined && (
          <Button type="button" variant="ghost" size="sm" onClick={() => onChange(undefined)}>
            Clear limit
          </Button>
        )}
      </PopoverContent>
    </Popover>
  )
}

/* The board */

export type ClickupKanbanProps = {
  /** List name in the header. */
  name?: string
  /** Saves tasks and board settings to localStorage under this key. */
  storageKey?: string
  className?: string
}

type Saved = { tasks: Task[]; lanes: Lanes; limits: { [s: string]: number }; collapsed: StatusKey[] }

export function ClickupKanban({ name = "Marketing", storageKey, className }: ClickupKanbanProps) {
  const [tasks, setTasks] = React.useState<Task[]>(seedTasks)
  const [lanes, setLanes] = React.useState<Lanes>("none")
  const [limits, setLimits] = React.useState<{ [s: string]: number }>({ progress: 4 })
  const [collapsed, setCollapsed] = React.useState<StatusKey[]>([])
  const [ready, setReady] = React.useState(!storageKey)
  const [query, setQuery] = React.useState("")
  const [meMode, setMeMode] = React.useState(false)
  const [showClosed, setShowClosed] = React.useState(true)
  const [priorityFilter, setPriorityFilter] = React.useState<PriorityKey[]>([])
  const [tagFilter, setTagFilter] = React.useState<string[]>([])
  const [openSubtasks, setOpenSubtasks] = React.useState<string[]>([])
  const [closedLanes, setClosedLanes] = React.useState<string[]>([])
  const [open, setOpen] = React.useState<string | null>(null)
  // The board while a card is dragged. It goes back to null on drop.
  const [dragItems, setDragItems] = React.useState<Items | null>(null)

  React.useEffect(() => {
    if (!storageKey) return
    try {
      const saved = JSON.parse(window.localStorage.getItem(storageKey) ?? "null") as Saved | null
      if (saved?.tasks?.length) {
        setTasks(saved.tasks)
        setLanes(saved.lanes)
        setLimits(saved.limits)
        setCollapsed(saved.collapsed)
      }
    } catch {
      // A broken save falls back to the sample board.
    }
    setReady(true)
  }, [storageKey])

  React.useEffect(() => {
    if (!ready || !storageKey) return
    try {
      window.localStorage.setItem(storageKey, JSON.stringify({ tasks, lanes, limits, collapsed } satisfies Saved))
    } catch {
      // Storage can be full or blocked.
    }
  }, [tasks, lanes, limits, collapsed, ready, storageKey])

  const update = (id: string, patch: Partial<Task>) => {
    const t = tasks.find((x) => x.id === id)
    if (patch.status === "done" && t && t.status !== "done") cue("success", { emphasis: "subtle", force: true })
    setTasks((ts) => ts.map((x) => (x.id === id ? { ...x, ...patch } : x)))
  }
  const remove = (ids: string[]) => {
    cue("close", { emphasis: "strong", force: true })
    setTasks((ts) => ts.filter((t) => !ids.includes(t.id) && !ids.includes(t.parent ?? "")))
  }
  const addTask = (taskName: string, patch: Partial<Task> = {}) => {
    const t: Task = {
      id: `t-${uid()}`,
      name: taskName,
      status: "todo",
      assignees: meMode ? [ME] : [],
      due: null,
      priority: "none",
      tags: [],
      budget: null,
      description: "",
      parent: null,
      ...patch,
    }
    setTasks((ts) => [...ts, t])
    return t.id
  }

  const statuses = STATUSES.filter((s) => showClosed || s.key !== "done")
  const laneKeys = lanes === "priority" ? PRIORITIES.map((p) => p.key as string) : ["all"]
  const laneOf = (t: Task) => (lanes === "priority" ? t.priority : "all")
  const childrenOf = (id: string) => tasks.filter((t) => t.parent === id)
  const q = query.trim().toLowerCase()
  const visible = (t: Task) =>
    !t.parent &&
    (showClosed || t.status !== "done") &&
    (!meMode || t.assignees.includes(ME)) &&
    (!priorityFilter.length || priorityFilter.includes(t.priority)) &&
    (!tagFilter.length || t.tags.some((x) => tagFilter.includes(x))) &&
    (!q || t.name.toLowerCase().includes(q))

  const baseItems = React.useMemo(() => {
    const items: Items = {}
    for (const lane of laneKeys) for (const s of statuses) items[containerId(lane, s.key)] = []
    for (const t of tasks) if (visible(t)) items[containerId(laneOf(t), t.status)]?.push(t.id)
    return items
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tasks, lanes, showClosed, meMode, priorityFilter, tagFilter, q])
  const items = dragItems ?? baseItems
  const byId = new Map(tasks.map((t) => [t.id, t]))
  const countOf = (s: StatusKey) => laneKeys.reduce((n, lane) => n + (items[containerId(lane, s)]?.length ?? 0), 0)

  /* Drag and drop. The Kanban primitive moves cards, the drop updates the tasks. */

  const onDragEnd = ({ id, to, value: final }: KanbanDragEnd<string>) => {
    setDragItems(null)
    const { lane, status: s } = parseContainer(to)
    const task = byId.get(id)!
    const patch: Partial<Task> = { status: s }
    if (lanes === "priority") patch.priority = lane as PriorityKey
    const limit = limits[s]
    const count = laneKeys.reduce((n, l) => n + (final[containerId(l, s)]?.length ?? 0), 0)
    if (s !== task.status && limit !== undefined && count > limit) cue("warning", { force: true })
    if (s === "done" && task.status !== "done") cue("success", { force: true })
    // Rebuild the task order from the board, so the drop position sticks.
    const order = Object.values(final).flat()
    setTasks((ts) => {
      const next = ts.map((t) => (t.id === id ? { ...t, ...patch } : t))
      const rank = new Map(order.map((x, i) => [x, i]))
      return [...next].sort((x, y) => (rank.get(x.id) ?? 1e6 + next.indexOf(x)) - (rank.get(y.id) ?? 1e6 + next.indexOf(y)))
    })
  }

  const filterCount = priorityFilter.length + tagFilter.length

  const toggleCollapse = (s: StatusKey) =>
    setCollapsed((c) => (c.includes(s) ? c.filter((x) => x !== s) : [...c, s]))

  // The status header for a column: pill, count and limit, menu and add.
  const columnHeader = (s: (typeof STATUSES)[number], lane: string) => {
    const isCollapsed = collapsed.includes(s.key)
    const count = countOf(s.key)
    if (isCollapsed)
      return (
        <Button
          type="button"
          variant="ghost"
          aria-label={`Expand ${s.label}`}
          onClick={() => toggleCollapse(s.key)}
          className="t-swap-in h-auto w-full flex-col justify-start gap-3 px-0 py-2"
        >
          <Badge
            className="h-auto rounded-md border-0 px-0.5 py-2 text-xs font-semibold tracking-wide text-white uppercase [writing-mode:vertical-rl]"
            style={{ background: s.color }}
          >
            {s.label}
          </Badge>
          <span className="text-xs text-muted-foreground tabular-nums">{count}</span>
        </Button>
      )
    return (
      <div className="flex items-center gap-1.5 px-1">
        <Badge className="h-6 gap-1.5 rounded-md border-0 px-2 text-xs font-semibold tracking-wide text-white uppercase" style={{ background: s.color }}>
          <s.icon weight={s.key === "done" ? "bold" : "duotone"} className="size-3.5" />
          {s.label}
        </Badge>
        <WipLimit
          count={count}
          limit={limits[s.key]}
          onChange={(v) =>
            setLimits((l) => {
              const next = { ...l }
              if (v === undefined) delete next[s.key]
              else next[s.key] = v
              return next
            })
          }
        />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button type="button" variant="ghost" size="icon-xs" aria-label={`${s.label} options`} className="ml-auto">
              <DotsThreeIcon weight="bold" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onSelect={() => toggleCollapse(s.key)}>
              <ArrowsInLineHorizontalIcon weight="bold" />
              Collapse column
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
        <Button
          type="button"
          variant="ghost"
          size="icon-xs"
          aria-label={`Add task to ${s.label}`}
          onClick={() => setOpen(addTask("Untitled task", { status: s.key, ...(lane !== "all" ? { priority: lane as PriorityKey } : {}) }))}
        >
          <PlusIcon weight="bold" />
        </Button>
      </div>
    )
  }

  const columnBody = (s: (typeof STATUSES)[number], lane: string) => {
    const id = containerId(lane, s.key)
    const ids = items[id] ?? []
    if (collapsed.includes(s.key)) return null
    return (
      <>
        <KanbanColumnContent>
          {ids.map((tid) => {
            const t = byId.get(tid)
            if (!t) return null
            return (
              <KanbanItem key={tid} value={tid} asHandle>
                <TaskCard
                  task={t}
                  subtasks={childrenOf(tid)}
                  open={openSubtasks.includes(tid)}
                  onToggleSubtasks={() =>
                    setOpenSubtasks((o) => (o.includes(tid) ? o.filter((x) => x !== tid) : [...o, tid]))
                  }
                  onOpen={() => setOpen(tid)}
                  update={update}
                />
              </KanbanItem>
            )
          })}
        </KanbanColumnContent>
        <AddTaskForm
          inset={false}
          onAdd={(n) => addTask(n, { status: s.key, ...(lane !== "all" ? { priority: lane as PriorityKey } : {}) })}
        />
      </>
    )
  }

  return (
    <Card className={cn("relative w-full gap-0 py-0 text-sm", className)}>
      {/* Header: list name and views */}
      <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 pt-3 pb-2">
        <div className="flex items-center gap-2">
          <span className="flex size-7 items-center justify-center rounded-md bg-rose-500 text-white">
            <RocketIcon weight="duotone" className="size-4" />
          </span>
          <span className="text-base font-semibold">{name}</span>
          <FavoriteButton kind="star" label="Star list" size="sm" />
        </div>
        <Tabs defaultValue="board">
          <TabsList>
            <TabsTrigger value="list" disabled>
              <ListBulletsIcon weight="bold" />
              List
            </TabsTrigger>
            <TabsTrigger value="board">
              <KanbanIcon weight="duotone" />
              Board
            </TabsTrigger>
            <TabsTrigger value="calendar" disabled>
              <CalendarBlankIcon weight="duotone" />
              Calendar
            </TabsTrigger>
          </TabsList>
        </Tabs>
      </div>

      {/* Every board control sits in this one row */}
      <div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1.5 [scrollbar-width:none]">
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <ToolbarButton
              icon={RowsIcon}
              label={lanes === "priority" ? "Status / Priority" : "Status"}
              active={lanes !== "none"}
              tint="bg-violet-500/15 text-violet-900 hover:bg-violet-500/20 dark:text-violet-100"
            />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start">
            <DropdownMenuLabel>Swimlanes</DropdownMenuLabel>
            <MenuChoices value={lanes} onValueChange={(v) => setLanes(v as Lanes)}>
              <MenuChoice value="none">None</MenuChoice>
              <MenuChoice value="priority">Priority</MenuChoice>
            </MenuChoices>
          </DropdownMenuContent>
        </DropdownMenu>
        <Popover>
          <PopoverTrigger asChild>
            <ToolbarButton
              icon={FunnelSimpleIcon}
              weight="bold"
              label={filterCount ? `${filterCount} filters` : "Filters"}
              active={filterCount > 0}
              tint="bg-emerald-500/15 text-emerald-900 hover:bg-emerald-500/20 dark:text-emerald-100"
            />
          </PopoverTrigger>
          <PopoverContent align="start" className="w-64 p-2">
            <p className="px-2 py-1 text-xs font-medium text-muted-foreground">Priority</p>
            {PRIORITIES.map((p) => (
              <Item key={p.key} asChild size="xs" className="cursor-pointer flex-nowrap rounded-md border-0 px-2 py-1.5 hover:bg-muted">
                <label>
                  <Checkbox
                    checked={priorityFilter.includes(p.key)}
                    onCheckedChange={(on) => setPriorityFilter((f) => (on ? [...f, p.key] : f.filter((x) => x !== p.key)))}
                  />
                  <Flag value={p.key} />
                  {p.label}
                </label>
              </Item>
            ))}
            <p className="mt-1 border-t px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground">Tags</p>
            {TAGS.map((t) => (
              <Item key={t} asChild size="xs" className="cursor-pointer flex-nowrap rounded-md border-0 px-2 py-1.5 hover:bg-muted">
                <label>
                  <Checkbox checked={tagFilter.includes(t)} onCheckedChange={(on) => setTagFilter((f) => (on ? [...f, t] : f.filter((x) => x !== t)))} />
                  <Tag value={t} />
                </label>
              </Item>
            ))}
          </PopoverContent>
        </Popover>
        <Toggle
          size="sm"
          aria-label="Me mode"
          pressed={meMode}
          onPressedChange={setMeMode}
          className="shrink-0 font-normal data-[state=on]:bg-sky-500/15 data-[state=on]:text-sky-900 dark:data-[state=on]:text-sky-100"
        >
          <UserIcon weight="duotone" />
          <span className="hidden sm:inline">Me mode</span>
        </Toggle>
        <Toggle
          size="sm"
          aria-label="Show closed"
          pressed={showClosed}
          onPressedChange={setShowClosed}
          className="shrink-0 font-normal data-[state=on]:bg-sky-500/15 data-[state=on]:text-sky-900 dark:data-[state=on]:text-sky-100"
        >
          <CheckCircleIcon weight="bold" />
          <span className="hidden sm:inline">Show closed</span>
        </Toggle>
        <div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
          <InputGroup className="w-36 lg:w-52">
            <InputGroupAddon>
              <MagnifyingGlassIcon weight="duotone" />
            </InputGroupAddon>
            <InputGroupInput placeholder="Search tasks" value={query} onChange={(e) => setQuery(e.target.value)} />
          </InputGroup>
          <Button type="button" size="sm" aria-label="Add task" onClick={() => setOpen(addTask("Untitled task"))}>
            <PlusIcon weight="bold" />
            <span className="hidden sm:inline">Task</span>
          </Button>
        </div>
      </div>

      {/* The board */}
      <Kanban
        value={items}
        onValueChange={setDragItems}
        onDragEnd={onDragEnd}
        onDragCancel={() => setDragItems(null)}
      >
        <ScrollFadeArea fade="y" scrollbars="both" className="max-h-[44rem]">
          <KanbanBoard className="block overflow-visible p-3">
            {lanes === "none" ? (
              <div className="flex items-start gap-3">
                {statuses.map((s) => (
                  <KanbanColumn
                    key={s.key}
                    value={containerId("all", s.key)}
                    collapsed={collapsed.includes(s.key)}
                    style={tint(s.color)}
                    className={COLUMN}
                  >
                    {columnHeader(s, "all")}
                    {columnBody(s, "all")}
                  </KanbanColumn>
                ))}
              </div>
            ) : (
              <div className="flex w-max flex-col gap-3">
                {/* Status headers once, then one row per priority */}
                <div className="flex gap-3">
                  {statuses.map((s) => (
                    <KanbanColumnHeader
                      key={s.key}
                      data-collapsed={collapsed.includes(s.key) || undefined}
                      style={{ ...tint(s.color), transition: "width var(--resize-dur) var(--resize-ease)" }}
                      className={cn(COLUMN, "block shrink-0", collapsed.includes(s.key) ? "w-11" : "w-[min(17rem,82vw)]")}
                    >
                      {columnHeader(s, "all")}
                    </KanbanColumnHeader>
                  ))}
                </div>
                {laneKeys.map((lane) => {
                  const p = priority(lane as PriorityKey)
                  const laneClosed = closedLanes.includes(lane)
                  const laneCount = statuses.reduce((n, s) => n + (items[containerId(lane, s.key)]?.length ?? 0), 0)
                  return (
                    <Collapsible
                      key={lane}
                      open={!laneClosed}
                      onOpenChange={() => setClosedLanes((c) => (c.includes(lane) ? c.filter((x) => x !== lane) : [...c, lane]))}
                      className="flex flex-col gap-2"
                    >
                      <div className="flex items-center gap-1.5">
                        <CollapsibleTrigger asChild>
                          <Button
                            type="button"
                            variant="ghost"
                            size="icon-xs"
                            aria-label={laneClosed ? `Expand ${p.label}` : `Collapse ${p.label}`}
                          >
                            {laneClosed ? <CaretRightIcon weight="bold" /> : <CaretDownIcon weight="bold" />}
                          </Button>
                        </CollapsibleTrigger>
                        <FlagIcon weight="duotone" className="size-4" style={{ color: p.color }} />
                        <span className="font-medium" style={{ color: lane === "none" ? undefined : p.color }}>
                          {p.label}
                        </span>
                        <span className="text-xs text-muted-foreground tabular-nums">{laneCount}</span>
                      </div>
                      <CollapsibleContent className="t-swap-in flex items-start gap-3">
                        {statuses.map((s) => (
                          <KanbanColumn
                            key={s.key}
                            value={containerId(lane, s.key)}
                            collapsed={collapsed.includes(s.key)}
                            style={tint(s.color)}
                            className={COLUMN}
                          >
                            {columnBody(s, lane)}
                          </KanbanColumn>
                        ))}
                      </CollapsibleContent>
                    </Collapsible>
                  )
                })}
              </div>
            )}
          </KanbanBoard>
        </ScrollFadeArea>
        <KanbanOverlay>
          {({ id }) => {
            const task = byId.get(id)
            return task ? (
              <div className="w-[min(16rem,78vw)]">
                <TaskCard
                  task={task}
                  subtasks={childrenOf(task.id)}
                  open={false}
                  onToggleSubtasks={() => {}}
                  onOpen={() => {}}
                  update={() => {}}
                  overlay
                />
              </div>
            ) : null
          }}
        </KanbanOverlay>
      </Kanban>

      <TaskPanel
        tasks={tasks}
        task={tasks.find((t) => t.id === open)}
        listName={name}
        update={update}
        remove={remove}
        addTask={addTask}
        onClose={() => setOpen(null)}
      />
    </Card>
  )
}
ClickUp ListRepeatable Group

On This Page

InstallationFeaturesBuilt withCode