Skip to content
oks-ui

App screens

Kanban board

A task board done as a real app screen: the same persistent sidebar/header shell as the other App screens patterns, a project header (avatars, invite, activity) with Date range/Members/Priority filter Dropdowns, and a Board whose cards carry a priority Chip, a read-only subtask checklist, an AvatarGroup, and comment/attachment counts. Pointer, touch, and keyboard drag all work.

kanban-board.tsx

How it's built

1

onItemMove reports `{ itemId, to: { columnId, index } }` — update your own state from it; Board never mutates your data.

2

The subtask checklist on each card is a read-only completion indicator (a plain styled `<span>`, not the real `Checkbox`) — the card itself is `role="button"` for Board's keyboard drag-and-drop, and any element with an interactive role nested inside it is a structural conflict (axe's `nested-interactive`) that a negative `tabindex` doesn't fix, since some assistive tech still reaches such controls through their own navigation. Real per-subtask toggling belongs in an expanded card-detail view, not the collapsed card face.

3

`renderColumnHeader` gets `(column, { count })` — the live card count per column comes from Board itself, not from counting your own items array.

4

Every pick-up, move, and drop is announced through a live region for keyboard and screen-reader users.

5

The sidebar and header are the same bounded-height app shell as analytics-dashboard: `overflow-hidden` on the outer wrapper, independent scroll on the sidebar, `shrink-0` on the header, so only the board area scrolls — sideways across columns and down within one, exactly like a real board.

6

Don't add `overflow-x-auto` on the div wrapping `Board` — `Board` already scrolls itself horizontally, and setting only `overflow-x` makes a browser compute `overflow-y` as `auto` too (the CSS rule for when one axis is `visible` and the other isn't), turning that wrapper into a second, redundant scroll boundary between each column's list and the page.

Source

One file · copy & paste
kanban-board.tsx
"use client";

import { useState } from "react";
import { Avatar, AvatarGroup } from "oks-ui/avatar";
import { Board } from "oks-ui/board";
import type { BoardColumnData } from "oks-ui/board";
import { Button } from "oks-ui/button";
import { Card, CardBody } from "oks-ui/card";
import { Chip } from "oks-ui/chip";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { FormFieldSet } from "oks-ui/form-field-set";
import type { NavItemData } from "oks-ui/nav";
import { PageTitle } from "oks-ui/page-title";
import "oks-ui/avatar.css";
import "oks-ui/board.css";
import "oks-ui/button.css";
import "oks-ui/card.css";
import "oks-ui/chip.css";
import "oks-ui/dropdown.css";
import "oks-ui/form-field-set.css";
import "oks-ui/page-title.css";
import { PatternAppShell } from "../PatternAppShell";
import {
  ArrowsRightLeftIcon,
  ChevronDownIcon,
  ClockIcon,
  GridIcon,
  HelpCircleIcon,
  MailIcon,
  MessageCircleIcon,
  PieChartIcon,
  PlusIcon,
  ReceiptIcon,
  SearchIcon,
  ZapIcon,
} from "../../showcase/icons";

// A simple pencil — "edit project name".
function PencilIcon({ size = 14 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M11 2.5 13.5 5 5 13.5H2.5V11Z" />
    </svg>
  );
}

// A paperclip — attachment counts on a card.
function PaperclipIcon({ size = 13 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M11 4.5 6 9.5a2 2 0 1 0 2.8 2.8l4.5-4.5a3.5 3.5 0 1 0-5-5L3.8 7.3" />
    </svg>
  );
}

const NAV_ITEMS: NavItemData[] = [
  {
    key: "main",
    label: "Main menu",
    isSection: true,
    children: [
      { key: "dashboard", label: "Dashboard", icon: <GridIcon size={18} /> },
      { key: "analytics", label: "Analytics", icon: <PieChartIcon size={18} /> },
      { key: "boards", label: "Boards", icon: <ArrowsRightLeftIcon size={18} /> },
      { key: "invoices", label: "Invoices", icon: <ReceiptIcon size={18} /> },
    ],
  },
  {
    key: "features",
    label: "Features",
    isSection: true,
    children: [
      { key: "recurring", label: "Time records", icon: <ClockIcon size={18} /> },
      { key: "feedback", label: "Feedback", icon: <MessageCircleIcon size={18} /> },
    ],
  },
];

type Priority = "High" | "Medium" | "Low";

type Task = {
  id: string;
  title: string;
  column: string;
  priority: Priority;
  assignees: string[];
  subtasks?: { label: string; done: boolean }[];
  comments?: number;
  attachments?: number;
};

// Real photos for the three named teammates — two of the site's existing
// stock-free portrait assets plus an AI-synthesized (non-real-person) face,
// the same pool used in the data-table-view pattern's recipients.
const ASSIGNEE_PHOTOS: Record<string, string> = {
  "Ava Moreno": "/hero/person-woman.jpg",
  "Nick Robins": "/hero/person-man.jpg",
  "Ken Tanaka": "/avatars/synthetic-1.jpg",
};

const priorityTone: Record<Priority, "danger" | "warning" | "info"> = {
  High: "danger",
  Medium: "warning",
  Low: "info",
};

const initialTasks: Task[] = [
  { id: "1", title: "Create landing page variations for portfolio website", column: "todo", priority: "High", assignees: ["Ava Moreno", "Ken Tanaka"], subtasks: [{ label: "Upload to Dropbox", done: true }, { label: "Share to email", done: false }], comments: 4, attachments: 2 },
  { id: "2", title: "Build rough prototype", column: "todo", priority: "Medium", assignees: ["Ava Moreno"], comments: 2 },
  { id: "3", title: "Responsiveness of gallery page", column: "todo", priority: "Low", assignees: ["Ava Moreno", "Nick Robins", "Ken Tanaka"], comments: 1, attachments: 1 },
  { id: "4", title: "Draft the client kickoff email", column: "todo", priority: "Medium", assignees: ["Nick Robins"] },
  { id: "5", title: "Create userflow", column: "inprogress", priority: "High", assignees: ["Ava Moreno"] },
  { id: "6", title: "Something that'll be worked on currently", column: "inprogress", priority: "High", assignees: ["Ava Moreno"], subtasks: [{ label: "Wireframe review", done: true }, { label: "Share in Slack", done: false }, { label: "Client sign-off", done: false }] },
  { id: "7", title: "Create moodboard", column: "inreview", priority: "High", assignees: ["Ava Moreno", "Ken Tanaka"], comments: 1, attachments: 1 },
  { id: "8", title: "This task is under review", column: "inreview", priority: "High", assignees: ["Ava Moreno"], comments: 3 },
  { id: "9", title: "Create userflow", column: "inreview", priority: "Medium", assignees: ["Ava Moreno", "Nick Robins"], comments: 1, attachments: 1 },
  { id: "10", title: "Portfolio website requirements meeting", column: "done", priority: "High", assignees: ["Ava Moreno", "Nick Robins", "Ken Tanaka"], comments: 3, attachments: 7 },
  { id: "11", title: "Responsiveness of gallery page", column: "done", priority: "Low", assignees: ["Ava Moreno"], comments: 8 },
];

const COLUMNS: BoardColumnData[] = [
  { id: "todo", title: "To do" },
  { id: "inprogress", title: "In progress" },
  { id: "inreview", title: "In review" },
  { id: "done", title: "Done" },
];

function ColumnMenu() {
  return (
    <Dropdown placement="bottom-end">
      <DropdownTrigger>
        <Button isIconOnly aria-label="Column options" variant="ghost" radius="full" size="xs">
          <ChevronDownIcon size={14} />
        </Button>
      </DropdownTrigger>
      <DropdownMenu aria-label="Column options">
        <DropdownItem key="rename">Rename column</DropdownItem>
        <DropdownItem key="limit">Set WIP limit</DropdownItem>
        <DropdownItem key="clear" classNames={{ title: "text-[color:var(--oks-color-danger-700)]" }}>
          Clear column
        </DropdownItem>
      </DropdownMenu>
    </Dropdown>
  );
}

export function KanbanBoard() {
  const [tasks, setTasks] = useState(initialTasks);

  return (
    <PatternAppShell
      navItems={NAV_ITEMS}
      selectedNavKey="boards"
      headerLeft={
        <FormFieldSet
          type="search"
          name="search"
          label="Search"
          placeholder="Search tasks…"
          startIcon={<SearchIcon size={16} />}
          classNames={{ base: "w-full max-w-xs", label: "sr-only", control: "border-transparent bg-paper-sunken" }}
        />
      }
      headerExtra={
        <>
          <Button isIconOnly aria-label="Help" variant="ghost" radius="full" size="sm">
            <HelpCircleIcon size={18} />
          </Button>
          <Button isIconOnly aria-label="Messages" variant="ghost" radius="full" size="sm">
            <MailIcon size={18} />
          </Button>
        </>
      }
      contentClassName="flex flex-1 flex-col gap-4 overflow-hidden p-5 @3xl:p-8"
    >
      <div className="flex shrink-0 flex-wrap items-center justify-between gap-3">
        <div className="flex items-center gap-2">
          <PageTitle
            as="h2"
            title="Project A"
            classNames={{ base: "flex-col items-start", title: "text-xl font-bold tracking-tight text-ink" }}
          />
          <Button isIconOnly aria-label="Rename project" variant="ghost" size="xs">
            <PencilIcon />
          </Button>
        </div>
        <div className="flex items-center gap-2">
          <AvatarGroup max={3}>
            <Avatar src={ASSIGNEE_PHOTOS["Ava Moreno"]} name="Ava Moreno" size="sm" radius="full" />
            <Avatar src={ASSIGNEE_PHOTOS["Nick Robins"]} name="Nick Robins" size="sm" radius="full" />
            <Avatar src={ASSIGNEE_PHOTOS["Ken Tanaka"]} name="Ken Tanaka" size="sm" radius="full" />
          </AvatarGroup>
          <Button isIconOnly aria-label="Invite member" variant="bordered" radius="full" size="sm">
            <PlusIcon size={14} />
          </Button>
          <Button variant="bordered" radius="full" size="sm" startContent={<ZapIcon size={14} />}>
            Activity
          </Button>
        </div>
      </div>

      <div className="flex shrink-0 flex-wrap items-center gap-2">
        <Dropdown placement="bottom-start">
          <DropdownTrigger>
            <Button variant="bordered" size="sm" endContent={<ChevronDownIcon size={14} />}>
              Date range
            </Button>
          </DropdownTrigger>
          <DropdownMenu aria-label="Date range">
            <DropdownItem key="week">This week</DropdownItem>
            <DropdownItem key="month">This month</DropdownItem>
            <DropdownItem key="quarter">This quarter</DropdownItem>
          </DropdownMenu>
        </Dropdown>
        <Dropdown placement="bottom-start">
          <DropdownTrigger>
            <Button variant="bordered" size="sm" endContent={<ChevronDownIcon size={14} />}>
              Members
            </Button>
          </DropdownTrigger>
          <DropdownMenu aria-label="Filter by member" selectionMode="multiple">
            <DropdownItem key="ava">Ava Moreno</DropdownItem>
            <DropdownItem key="nick">Nick Robins</DropdownItem>
            <DropdownItem key="ken">Ken Tanaka</DropdownItem>
          </DropdownMenu>
        </Dropdown>
        <Dropdown placement="bottom-start">
          <DropdownTrigger>
            <Button variant="bordered" size="sm" endContent={<ChevronDownIcon size={14} />}>
              Priority
            </Button>
          </DropdownTrigger>
          <DropdownMenu aria-label="Filter by priority" selectionMode="multiple">
            <DropdownItem key="high">High</DropdownItem>
            <DropdownItem key="medium">Medium</DropdownItem>
            <DropdownItem key="low">Low</DropdownItem>
          </DropdownMenu>
        </Dropdown>
      </div>

      {/* No overflow-x-auto here — Board already scrolls itself
          horizontally. Adding it on this wrapper too triggers a real
          CSS quirk: setting only overflow-x makes the browser compute
          overflow-y as auto as well (the "one axis visible, one not"
          rule), turning this div into a second, redundant scroll
          boundary sitting between each column's own list and the
          page — which is exactly what made column scrolling flaky. */}
      <div className="min-h-0 flex-1">
        <Board<Task>
          aria-label="Project A board"
          columns={COLUMNS}
          items={tasks}
          columnWidth={252}
          getItemId={(t) => t.id}
          getItemColumn={(t) => t.column}
          onItemMove={({ itemId, to }) =>
            setTasks((prev) => {
              const moved = prev.find((t) => t.id === itemId);
              if (!moved) return prev;
              const without = prev.filter((t) => t.id !== itemId);
              const inTarget = without.filter((t) => t.column === to.columnId);
              const rest = without.filter((t) => t.column !== to.columnId);
              inTarget.splice(to.index, 0, { ...moved, column: to.columnId });
              return [...rest, ...inTarget];
            })
          }
          renderColumnHeader={(column, { count }) => (
            <div className="flex w-full items-center justify-between">
              <span className="text-[0.8rem] font-semibold uppercase tracking-wide text-ink-muted">
                {column.title} <span className="text-ink-muted">({count})</span>
              </span>
              <div className="flex items-center gap-0.5">
                <ColumnMenu />
                <Button isIconOnly aria-label={`Add task to ${column.title}`} variant="ghost" size="xs">
                  <PlusIcon size={14} />
                </Button>
              </div>
            </div>
          )}
          renderCard={(t) => (
            <Card shadow="xs" isHoverable>
              <CardBody className="flex flex-col gap-2.5 p-3">
                <Chip size="sm" variant="soft" color={priorityTone[t.priority]} classNames={{ base: "self-start" }}>
                  {t.priority.toUpperCase()}
                </Chip>
                <p className="text-[0.85rem] font-medium leading-snug text-ink">{t.title}</p>

                {t.subtasks ? (
                  <div className="flex flex-col gap-1.5">
                    <p className="text-[0.68rem] font-semibold uppercase tracking-wide text-ink-faint">
                      Subtasks {t.subtasks.filter((s) => s.done).length}/{t.subtasks.length}
                    </p>
                    {/* A real <Checkbox> here -- any element carrying an
                        interactive role -- nested inside the card (itself
                        role="button" for Board's keyboard drag-and-drop) is a
                        structural nested-interactive conflict axe flags
                        regardless of tabindex: some assistive tech still
                        reaches negative-tabindex controls through their own
                        navigation. This card face is a preview, not a form,
                        so each subtask is a read-only completion indicator --
                        matches the "Subtasks N/M" count already above it. */}
                    {t.subtasks.map((s) => (
                      <div key={s.label} className="flex items-center gap-2">
                        <span
                          aria-hidden="true"
                          className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-[3px] border ${s.done ? "border-accent bg-accent text-white" : "border-line"}`}
                        >
                          {s.done ? (
                            <svg viewBox="0 0 12 10" fill="none" className="h-2.5 w-2.5">
                              <path d="M1 5l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
                            </svg>
                          ) : null}
                        </span>
                        <span className={`text-[0.8rem] ${s.done ? "text-ink-faint line-through" : "text-ink-soft"}`}>
                          <span className="sr-only">{s.done ? "Done: " : "Not done: "}</span>
                          {s.label}
                        </span>
                      </div>
                    ))}
                  </div>
                ) : null}

                <div className="mt-1 flex items-center justify-between">
                  <AvatarGroup max={3}>
                    {t.assignees.map((name) => (
                      <Avatar key={name} src={ASSIGNEE_PHOTOS[name]} name={name} size="xs" radius="full" />
                    ))}
                  </AvatarGroup>
                  {t.attachments || t.comments ? (
                    <div className="flex items-center gap-2.5 text-[0.75rem] text-ink-faint">
                      {t.attachments ? (
                        <span className="flex items-center gap-1">
                          <PaperclipIcon /> {t.attachments}
                        </span>
                      ) : null}
                      {t.comments ? (
                        <span className="flex items-center gap-1">
                          <MessageCircleIcon size={13} /> {t.comments}
                        </span>
                      ) : null}
                    </div>
                  ) : null}
                </div>
              </CardBody>
            </Card>
          )}
        />
      </div>
    </PatternAppShell>
  );
}

More patterns

App screens
Analytics dashboardA full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.
App screens
Settings panelA real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.
App screens
Data table viewA full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.