Board — compositional kanban / sortable-list primitive
boarduiSix small composable components for any board / kanban / sortable-list surface — opinionated about drop targeting and animation, agnostic about layout, data, and chrome. Drop `<Board>` around a grid of `<BoardLane>` columns; nest `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. State lives in a sibling `useBoard()` hook (insertion-index drop math, keyboard a11y, accept predicate). Cards use native HTML5 drag-and-drop (draggable + onDragStart / onLaneDragOver / onLaneDrop) — the lane components emit drag events up to the consumer, which threads them through useBoard and feeds the resulting state back in as props. Three-level React context (board → lane → card) mirrors the Timeline / Card sub-component pattern.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/board.json$npx shadcn@latest add https://uipkge.dev/r/lit/board.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/board.json$bunx shadcn@latest add https://uipkge.dev/r/lit/board.jsonhttps://uipkge.dev/r/lit/board.jsonInstalls to:src/uip/components/board/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
orientation | BoardOrientation | horizontal | optional |
density | BoardDensity | default | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-board': UipBoard
'uip-board-lane': UipBoardLane
'uip-board-lane-header': UipBoardLaneHeader
'uip-board-lane-body': UipBoardLaneBody
'uip-board-lane-empty': UipBoardLaneEmpty
'uip-board-card': UipBoardCard
}
}npm dependencies
Includes
Files installed (2)
src/uip/components/board/board.ts9.4 kB
import { LitElement, css, html, nothing } from 'lit' import { cn } from '../../lib/utils' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { boardCardVariants, boardLaneVariants } from './board.variants' export type BoardOrientation = 'horizontal' | 'vertical' export type BoardDensity = 'default' | 'compact' | 'comfortable' /* --------------------------------------------------------------------- Board */ export class UipBoard extends LitElement { static styles = [ tailwind, css` :host { display: block; width: 100%; } `, ] static properties = { orientation: { type: String }, density: { type: String }, draggingId: { state: true }, dragOverLaneId: { state: true }, selectedIds: { state: true }, } orientation: BoardOrientation = 'horizontal' density: BoardDensity = 'default' draggingId: string | null = null dragOverLaneId: string | null = null selectedIds = new Set<string>() private cardLaneMap = new Map<string, string>() constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'board') } setDragging(id: string | null) { this.draggingId = id } setDragOverLane(laneId: string | null) { this.dragOverLaneId = laneId } registerCardLane(cardId: string, laneId: string) { this.cardLaneMap.set(cardId, laneId) } moveItem(itemId: string | string[], toLaneId: string, toIndex = 0) { const ids = Array.isArray(itemId) ? itemId : [itemId] const fromLaneId = this.cardLaneMap.get(ids[0]) ?? '' this.dispatchEvent( new CustomEvent('change', { detail: { itemId: ids[0], itemIds: ids, from: fromLaneId, to: toLaneId, index: toIndex, }, bubbles: true, composed: true, }), ) } toggleSelection(cardId: string, additive = false) { const next = additive ? new Set(this.selectedIds) : new Set<string>() if (next.has(cardId)) next.delete(cardId) else next.add(cardId) this.selectedIds = next this.dispatchEvent( new CustomEvent('selection-change', { detail: { selectedIds: Array.from(next) }, bubbles: true, composed: true, }), ) this.requestUpdate() } render() { return html` <div part="base" class=${cn( 'w-full', this.orientation === 'horizontal' ? 'flex flex-row gap-4 overflow-x-auto' : 'flex flex-col gap-4', )} > <slot></slot> </div> ` } } /* ---------------------------------------------------------------- Board Lane */ export class UipBoardLane extends LitElement { static styles = [ tailwind, css` :host { display: flex; flex: 1 1 0%; min-width: 240px; } `, ] static properties = { laneId: { type: String, attribute: 'lane-id' }, tone: { type: String }, isOver: { state: true }, } laneId = '' tone: 'default' | 'plain' = 'default' isOver = false connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'board-lane') } private onDragOver(e: DragEvent) { e.preventDefault() this.isOver = true const board = this.closest('uip-board') as UipBoard | null board?.setDragOverLane(this.laneId) } private onDragLeave() { this.isOver = false } private onDrop(e: DragEvent) { e.preventDefault() this.isOver = false const rawData = e.dataTransfer?.getData('text/plain') if (rawData) { const board = this.closest('uip-board') as UipBoard | null try { const parsed = JSON.parse(rawData) const ids = Array.isArray(parsed) ? parsed : [parsed] board?.moveItem(ids, this.laneId) } catch { board?.moveItem(rawData, this.laneId) } } } render() { const state = this.isOver ? 'over' : 'idle' return html` <div part="base" data-lane-id=${this.laneId} class=${cn('w-full', boardLaneVariants({ tone: this.tone, state }))} @dragover=${this.onDragOver} @dragleave=${this.onDragLeave} @drop=${this.onDrop} > <slot></slot> </div> ` } } /* --------------------------------------------------------- Board Lane Header */ export class UipBoardLaneHeader extends LitElement { static styles = [ tailwind, css` :host { display: block; } `, ] connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'board-lane-header') } render() { return html` <div part="base" class="flex items-center justify-between gap-2 px-1"> <slot></slot> </div> ` } } /* ----------------------------------------------------------- Board Lane Body */ export class UipBoardLaneBody extends LitElement { static styles = [ tailwind, css` :host { display: block; width: 100%; flex: 1 1 0%; } `, ] connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'board-lane-body') } render() { return html` <div part="base" class="flex flex-1 flex-col gap-2 overflow-y-auto"> <slot></slot> </div> ` } } /* ---------------------------------------------------------- Board Lane Empty */ export class UipBoardLaneEmpty extends LitElement { static styles = [ tailwind, css` :host { display: block; } `, ] connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'board-lane-empty') } render() { return html` <div part="base" class="border-dashed border-border/70 text-muted-foreground flex h-20 items-center justify-center rounded-lg border-2 text-xs" > <slot>Empty lane</slot> </div> ` } } /* ---------------------------------------------------------------- Board Card */ export class UipBoardCard extends LitElement { static styles = [ tailwind, css` :host { display: block; width: 100%; } `, ] static properties = { cardId: { type: String, attribute: 'card-id' }, disabled: { type: Boolean }, isDragging: { state: true }, isSelected: { state: true }, } cardId = '' disabled = false isDragging = false isSelected = false connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'board-card') const lane = this.closest('uip-board-lane') as UipBoardLane | null const board = this.closest('uip-board') as UipBoard | null if (lane && board && this.cardId) { board.registerCardLane(this.cardId, lane.laneId) } } private onDragStart(e: DragEvent) { if (this.disabled) return this.isDragging = true const board = this.closest('uip-board') as UipBoard | null const idsToDrag = board && board.selectedIds.has(this.cardId) ? Array.from(board.selectedIds) : [this.cardId] e.dataTransfer?.setData('text/plain', JSON.stringify(idsToDrag)) board?.setDragging(this.cardId) } private onDragEnd() { this.isDragging = false const board = this.closest('uip-board') as UipBoard | null board?.setDragging(null) } private onClick(e: MouseEvent) { if (this.disabled) return const isAdditive = e.metaKey || e.ctrlKey || e.shiftKey const board = this.closest('uip-board') as UipBoard | null board?.toggleSelection(this.cardId, isAdditive) this.isSelected = Boolean(board?.selectedIds.has(this.cardId)) } render() { const board = this.closest('uip-board') as UipBoard | null const selected = board?.selectedIds.has(this.cardId) ?? this.isSelected const state = this.isDragging ? 'dragging' : 'idle' return html` <div part="base" draggable=${!this.disabled} data-card-id=${this.cardId} class=${cn( boardCardVariants({ state }), selected && 'ring-2 ring-primary/60 border-primary/50', this.disabled && 'pointer-events-none opacity-40 grayscale', )} @dragstart=${this.onDragStart} @dragend=${this.onDragEnd} @click=${this.onClick} > <slot></slot> </div> ` } } /* -------------------------------------------------------------- Registration */ customElements.get('uip-board') || customElements.define('uip-board', UipBoard) customElements.get('uip-board-lane') || customElements.define('uip-board-lane', UipBoardLane) customElements.get('uip-board-lane-header') || customElements.define('uip-board-lane-header', UipBoardLaneHeader) customElements.get('uip-board-lane-body') || customElements.define('uip-board-lane-body', UipBoardLaneBody) customElements.get('uip-board-lane-empty') || customElements.define('uip-board-lane-empty', UipBoardLaneEmpty) customElements.get('uip-board-card') || customElements.define('uip-board-card', UipBoardCard) declare global { interface HTMLElementTagNameMap { 'uip-board': UipBoard 'uip-board-lane': UipBoardLane 'uip-board-lane-header': UipBoardLaneHeader 'uip-board-lane-body': UipBoardLaneBody 'uip-board-lane-empty': UipBoardLaneEmpty 'uip-board-card': UipBoardCard } }src/uip/components/board/board.variants.ts1.6 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' export const boardLaneVariants = cva( 'flex min-h-0 flex-col gap-3 rounded-xl border p-3 motion-safe:transition-[background-color,border-color,box-shadow] motion-safe:duration-200 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)]', { variants: { tone: { default: 'bg-muted/30', plain: 'bg-transparent', }, state: { idle: '', over: 'border-primary/60 bg-primary/5 ring-2 ring-primary/30 ring-offset-1 ring-offset-background', rejecting: 'border-destructive/40 bg-destructive/5 ring-2 ring-destructive/30 ring-offset-1 ring-offset-background', }, }, defaultVariants: { tone: 'default', state: 'idle' }, }, ) export const boardCardVariants = cva( 'bg-card hover:border-primary/40 group block w-full cursor-grab rounded-md border p-2 text-left outline-none motion-safe:transition-[transform,box-shadow,opacity,border-color] motion-safe:duration-200 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-primary/30 active:cursor-grabbing', { variants: { state: { idle: 'shadow-sm motion-safe:hover:-translate-y-1 hover:shadow-md', dragging: 'scale-95 opacity-40 shadow-none', moved: 'shadow-sm ring-2 ring-primary/50 ring-offset-1 ring-offset-background', }, }, defaultVariants: { state: 'idle' }, }, ) export type BoardLaneVariantsProps = VariantProps<typeof boardLaneVariants> export type BoardCardVariantsProps = VariantProps<typeof boardCardVariants>
Raw manifest:https://uipkge.dev/r/lit/board.json