Menu

Change language

GitHub repo

Board — compositional kanban / sortable-list primitive

boardui

Six 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; slot `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. State lives in a sibling board state helper (insertion-index drop math, keyboard a11y, accept predicate). Mirrors the Timeline / Card sub-component pattern (three-level context: board → lane → card).

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/board.json
Manifest:https://uipkge.dev/r/svelte/board.jsonInstalls to:src/lib/components/ui/board/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
tone
'default''plain'
defaultoptional
state
'idle''over''rejecting'
idleoptional
orientationBoardOrientationhorizontaloptional
densityBoardDensitydefaultoptional
motion

Transition name applied by BoardLaneBody. Defaults to `motion-list`.

stringmotion-listoptional
accepts

Predicate run per drop. Defaults to always-accept.

BoardAcceptsFn—optional
moveItem

Imperative move from a parent's board state helper.

(itemId: string | string[], toLaneId: string, toIndex?: number) => void—optional
draggingId

External state — pass `state.draggingId` etc. from the board state helper.

string | null—optional
draggingIdsreadonly string[]—optional
dragOverLaneIdstring | null—optional
justMovedIdstring | null—optional
selectedIdsReadonlySet<string>—optional
toggleSelection

primitive can expose selection mutations through context (consumed by the BoardCard click handler). Both default to no-ops, so Board still works with consumers that don't wire selection.

(itemId: string, additive?: boolean) => void—optional
clearSelection() => void—optional
registerAllowedLanes(cardId: string, lanes: readonly string[] | undefined) => void—optional
unregisterAllowedLanes(cardId: string) => void—optional
registerLaneDisabled(laneId: string, disabled: boolean) => void—optional
unregisterLaneDisabled(laneId: string) => void—optional
isLaneAcceptingFor(laneId: string) => boolean—optional
ref

The root <div>, via `bind:ref`.

HTMLDivElement | null—optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

BoardDropEvent
interface BoardDropEvent {
  /** First (anchor) item moved. For multi-item drops, see `itemIds`. */
  itemId: string
  /** All items moved in this drop, in their final visual order. */
  itemIds: string[]
  from: string
  to: string
  /** Insertion index of the first item in the target lane. */
  index: number
}
BoardState
interface BoardState {
  draggingId?: string | null
  draggingIds?: readonly string[]
  dragOverLaneId?: string | null
  justMovedId?: string | null
  selectedIds?: ReadonlySet<string>
}
BoardContext
interface BoardContext {
  orientation: BoardOrientation
  density: BoardDensity
  /** Transition name for lane bodies. Defaults to `motion-list` (the @uipkge motion preset). */
  motion: string
  /** Currently-grabbed primary card id (pointer drag OR keyboard grab). */
  draggingId: string | null
  /** All cards being dragged this turn — usually `[draggingId]`, but
   *  expands to the full selection when the user grabs one of a multi-
   *  selected set. Lanes read this to compute drop math (the dragged
   *  cards are excluded from the insertion-index walk). */
  draggingIds: readonly string[]
  /** Lane currently under the pointer/keyboard cursor. */
  dragOverLaneId: string | null
  /** Card that just landed — used by consumers for a momentary highlight. */
  justMovedId: string | null
  /** Multi-select set. Click-and-drag any selected card moves the whole
   *  set; click a non-selected card to drag that one alone. */
  selectedIds: ReadonlySet<string>
  /** Predicate run by lanes; defaults to always-accept. */
  accepts: BoardAcceptsFn
  /** Imperative move — used by keyboard handlers + external callers. */
  moveItem: (itemId: string | string[], toLaneId: string, toIndex?: number) => void
  /** Toggle one item in the selection set (or replace it if `additive` is false). */
  toggleSelection: (itemId: string, additive?: boolean) => void
  /** Drop the entire selection. */
  clearSelection: () => void
  /** Per-card allow-list registry. BoardCard registers its own `allowedLanes`
   *  on mount; lanes consult this to short-circuit rejected drops without
   *  the consumer having to encode the rule inside `accepts`. */
  registerAllowedLanes: (cardId: string, lanes: readonly string[] | undefined) => void
  unregisterAllowedLanes: (cardId: string) => void
  /** Whether the lane's drops are accepted for the dragging item, given
   *  the current `accepts` + per-card allowedLanes + lane disabled state. */
  isLaneAcceptingFor: (laneId: string) => boolean
  /** Lane registration (mirrors Timeline pattern). */
  laneIds: symbol[]
  registerLane: (id: symbol) => void
  unregisterLane: (id: symbol) => void
  /** Lane disabled registry — lanes call register/unregister; board state
   *  helpers + isLaneAcceptingFor read from it. */
  registerLaneDisabled: (laneId: string, disabled: boolean) => void
  unregisterLaneDisabled: (laneId: string) => void
}
BoardLaneContext
interface BoardLaneContext {
  laneId: string
  isDragOver: boolean
  isAccepting: boolean
  disabled: boolean
}
BoardCardContext
interface BoardCardContext {
  cardId: string
  laneId: string
  isDragging: boolean
  isJustMoved: boolean
  isSelected: boolean
  disabled: boolean
}

npm dependencies

Files installed (9)

  • src/lib/components/ui/board/Board.svelte4.7 kB
    <script lang="ts" module>
      import type { HTMLAttributes } from 'svelte/elements'
      import type { BoardAcceptsFn, BoardDensity, BoardOrientation, BoardState } from './context'
    
      export interface BoardProps extends HTMLAttributes<HTMLDivElement> {
        /**
         * External state snapshot (`draggingId` / `draggingIds` / `dragOverLaneId` /
         * `justMovedId` / `selectedIds`) — the Svelte equivalent of spreading
         * React `useBoard`'s `state` into `<Board>`. Read-only: the board never
         * writes back (mutations flow through `moveItem` / `toggleSelection`).
         * When both are given, `state` fields win over the individual props below.
         */
        state?: BoardState
        orientation?: BoardOrientation
        density?: BoardDensity
        /** Transition name applied by BoardLaneBody. Defaults to `motion-list`. */
        motion?: string
        /** Predicate run per drop. Defaults to always-accept. */
        accepts?: BoardAcceptsFn
        /** Imperative move from a parent's board state helper. */
        moveItem?: (itemId: string | string[], toLaneId: string, toIndex?: number) => void
        /** External state — pass `state.draggingId` etc. from the board state helper. */
        draggingId?: string | null
        draggingIds?: readonly string[]
        dragOverLaneId?: string | null
        justMovedId?: string | null
        selectedIds?: ReadonlySet<string>
        /** Optional toggle/clearSelection from the board state helper so the
         *  primitive can expose selection mutations through context (consumed
         *  by the BoardCard click handler). Both default to no-ops, so Board
         *  still works with consumers that don't wire selection. */
        toggleSelection?: (itemId: string, additive?: boolean) => void
        clearSelection?: () => void
        registerAllowedLanes?: (cardId: string, lanes: readonly string[] | undefined) => void
        unregisterAllowedLanes?: (cardId: string) => void
        registerLaneDisabled?: (laneId: string, disabled: boolean) => void
        unregisterLaneDisabled?: (laneId: string) => void
        isLaneAcceptingFor?: (laneId: string) => boolean
        /** The root <div>, via `bind:ref`. */
        ref?: HTMLDivElement | null
      }
    </script>
    
    <script lang="ts">
      import { setContext } from 'svelte'
      import { cn } from '$lib/utils'
      import { BOARD_CONTEXT, type BoardContext } from './context'
    
      // NOTE: the public prop is `state`, but it is destructured as `boardState`
      // — a local named `state` would shadow the `$state` rune used below.
      let {
        class: className,
        state: boardState,
        orientation = 'horizontal',
        density = 'default',
        motion = 'motion-list',
        accepts = () => true,
        moveItem = () => {},
        draggingId = null,
        draggingIds = [],
        dragOverLaneId = null,
        justMovedId = null,
        selectedIds = new Set<string>(),
        toggleSelection = () => {},
        clearSelection = () => {},
        registerAllowedLanes = () => {},
        unregisterAllowedLanes = () => {},
        registerLaneDisabled = () => {},
        unregisterLaneDisabled = () => {},
        isLaneAcceptingFor = () => true,
        children,
        ref = $bindable(null),
        ...restProps
      }: BoardProps = $props()
    
      let laneIds = $state<symbol[]>([])
    
      // Getters keep the context reactive: children reading these inside
      // `$derived` re-run when the parent's props change.
      setContext<BoardContext>(BOARD_CONTEXT, {
        get orientation() {
          return orientation
        },
        get density() {
          return density
        },
        get motion() {
          return motion
        },
        get draggingId() {
          return boardState?.draggingId ?? draggingId
        },
        get draggingIds() {
          return boardState?.draggingIds ?? draggingIds
        },
        get dragOverLaneId() {
          return boardState?.dragOverLaneId ?? dragOverLaneId
        },
        get justMovedId() {
          return boardState?.justMovedId ?? justMovedId
        },
        get selectedIds() {
          return boardState?.selectedIds ?? selectedIds
        },
        get accepts() {
          return accepts
        },
        moveItem: (...args) => moveItem(...args),
        toggleSelection: (...args) => toggleSelection(...args),
        clearSelection: () => clearSelection(),
        registerAllowedLanes: (...args) => registerAllowedLanes(...args),
        unregisterAllowedLanes: (...args) => unregisterAllowedLanes(...args),
        registerLaneDisabled: (...args) => registerLaneDisabled(...args),
        unregisterLaneDisabled: (...args) => unregisterLaneDisabled(...args),
        isLaneAcceptingFor: (laneId) => isLaneAcceptingFor(laneId),
        get laneIds() {
          return laneIds
        },
        registerLane: (id) => {
          if (!laneIds.includes(id)) laneIds.push(id)
        },
        unregisterLane: (id) => {
          laneIds = laneIds.filter((i) => i !== id)
        },
      })
    </script>
    
    <div bind:this={ref} data-uipkge="" data-slot="board" data-orientation={orientation} class={cn('w-full', className)} {...restProps}>
      {@render children?.()}
    </div>
    
  • src/lib/components/ui/board/BoardLane.svelte4 kB
  • src/lib/components/ui/board/BoardLaneHeader.svelte0.6 kB
  • src/lib/components/ui/board/BoardLaneBody.svelte1.8 kB
  • src/lib/components/ui/board/BoardLaneEmpty.svelte0.8 kB
  • src/lib/components/ui/board/BoardCard.svelte8.3 kB
  • src/lib/components/ui/board/board.variants.ts2 kB
  • src/lib/components/ui/board/context.ts5.4 kB
  • src/lib/components/ui/board/index.ts1.1 kB

Raw manifest:https://uipkge.dev/r/svelte/board.json