
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
kanbanuiComposable compound Kanban primitive for building board, pipeline, and agile workflows with tactile card drag-and-drop mechanics.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/kanban.jsonnpx shadcn-vue@latest add @uipkge/kanbanInstalls to:app/components/ui/kanban/Multi-column agile board composing Kanban columns, drag-and-drop task cards, priority indicators, and member avatar stacks.
| Name | Type / Values | Default | Required |
|---|---|---|---|
isOver | 'true''false' | — | optional |
class | HTMLAttributes['class'] | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
KanbanMoveEventinterface KanbanMoveEvent {
cardId: string
fromColumnId: string
toColumnId: string
toIndex?: number
}KanbanContextinterface KanbanContext {
draggingCardId: Ref<string | null>
draggingColumnId: Ref<string | null>
overColumnId: Ref<string | null>
/** Set while a card is held by keyboard (Space), not by pointer drag. */
grabbedCardId: Ref<string | null>
setDraggingCard: (cardId: string | null, columnId: string | null) => void
setOverColumn: (columnId: string | null) => void
setGrabbedCard: (cardId: string | null) => void
emitMove: (event: KanbanMoveEvent) => void
/** Speak a message through the board's polite live region. */
announce: (message: string) => void
}KanbanColumnContextinterface KanbanColumnContext {
columnId: string
/** Column label, used to announce keyboard moves. Falls back to the id. */
label: Ref<string | undefined>
}<script setup lang="ts">
import { provide, ref, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { KanbanContextKey, type KanbanMoveEvent } from './context'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const emit = defineEmits<{
'card-move': [event: KanbanMoveEvent]
}>()
const draggingCardId = ref<string | null>(null)
const draggingColumnId = ref<string | null>(null)
const overColumnId = ref<string | null>(null)
const grabbedCardId = ref<string | null>(null)
// Keyboard moves are silent to a screen reader — the card just appears
// somewhere else. This region narrates pick up / move / drop / cancel.
const announcement = ref('')
function setDraggingCard(cardId: string | null, columnId: string | null) {
draggingCardId.value = cardId
draggingColumnId.value = columnId
}
function setOverColumn(columnId: string | null) {
overColumnId.value = columnId
}
function setGrabbedCard(cardId: string | null) {
grabbedCardId.value = cardId
}
function emitMove(event: KanbanMoveEvent) {
emit('card-move', event)
}
function announce(message: string) {
// Re-assigning the same string would not re-trigger the live region.
announcement.value = announcement.value === message ? `${message} ` : message
}
provide(KanbanContextKey, {
draggingCardId,
draggingColumnId,
overColumnId,
grabbedCardId,
setDraggingCard,
setOverColumn,
setGrabbedCard,
emitMove,
announce,
})
</script>
<template>
<div data-uipkge data-slot="kanban" :class="cn('w-full', props.class)">
<slot />
<div data-slot="kanban-live-region" class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{{ announcement }}
</div>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div data-slot="kanban-board" :class="cn('flex w-full items-start gap-4 overflow-x-auto pb-4', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, inject, provide, toRef, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { KanbanColumnContextKey, KanbanContextKey } from './context'
import { kanbanColumnVariants } from './kanban.variants'
interface Props {
id: string
/** Accessible name for the column, also used in keyboard move
* announcements ("moved to In progress"). Falls back to the id. */
label?: string
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const kanban = inject(KanbanContextKey)
provide(KanbanColumnContextKey, {
columnId: props.id,
label: toRef(props, 'label'),
})
const isOver = computed(() => kanban?.overColumnId.value === props.id)
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move'
}
if (kanban && kanban.overColumnId.value !== props.id) {
kanban.setOverColumn(props.id)
}
}
function handleDragLeave(e: DragEvent) {
const currentTarget = e.currentTarget as HTMLElement | null
const relatedTarget = e.relatedTarget as HTMLElement | null
if (currentTarget?.contains(relatedTarget)) return
if (kanban && kanban.overColumnId.value === props.id) {
kanban.setOverColumn(null)
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (kanban && kanban.draggingCardId.value && kanban.draggingColumnId.value) {
kanban.emitMove({
cardId: kanban.draggingCardId.value,
fromColumnId: kanban.draggingColumnId.value,
toColumnId: props.id,
})
}
kanban?.setDraggingCard(null, null)
kanban?.setOverColumn(null)
}
</script>
<template>
<div
data-uipkge
data-slot="kanban-column"
role="group"
:aria-label="label ?? id"
:data-column-id="id"
:data-over="isOver ? '' : undefined"
:class="cn(kanbanColumnVariants({ isOver }), props.class)"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<slot :is-over="isOver" />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div data-slot="kanban-column-header" :class="cn('flex items-center justify-between gap-2 px-1 py-0.5', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
color?: string
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
color: 'bg-primary',
})
</script>
<template>
<span data-slot="kanban-column-dot" :class="cn('size-2 shrink-0 rounded-full', props.color, props.class)" />
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<h3 data-slot="kanban-column-title" :class="cn('text-foreground text-sm font-semibold tracking-tight', props.class)">
<slot />
</h3>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
count?: number | string
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<span
data-slot="kanban-column-count"
:class="cn('bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs font-medium tabular-nums', props.class)"
>
<slot>{{ count }}</slot>
</span>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Plus } from 'lucide-vue-next'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<button
type="button"
data-slot="kanban-column-add"
:class="
cn(
'text-muted-foreground hover:bg-background hover:text-foreground focus-visible:ring-ring inline-flex size-6 items-center justify-center rounded-md transition-colors focus-visible:ring-1 focus-visible:outline-none',
props.class,
)
"
>
<slot>
<Plus class="size-3.5" />
</slot>
</button>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div data-slot="kanban-column-body" :class="cn('flex flex-1 flex-col gap-2 overflow-y-auto py-1', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div
data-slot="kanban-column-empty"
:class="
cn(
'border-border/60 text-muted-foreground/60 flex flex-1 items-center justify-center rounded-lg border border-dashed py-8 text-xs',
props.class,
)
"
>
<slot>No cards</slot>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { KanbanColumnContextKey, KanbanContextKey } from './context'
import { kanbanCardVariants } from './kanban.variants'
interface Props {
id: string
disabled?: boolean
/** Disable the keyboard grab (Space / arrows) while leaving pointer
* dragging intact. Default: enabled. */
keyboardDraggable?: boolean
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
keyboardDraggable: true,
})
const kanban = inject(KanbanContextKey)
const column = inject(KanbanColumnContextKey)
const cardEl = ref<HTMLElement | null>(null)
const isGrabbed = computed(() => kanban?.grabbedCardId.value === props.id)
const isDragging = computed(() => kanban?.draggingCardId.value === props.id || isGrabbed.value)
const canKeyboardDrag = computed(() => props.keyboardDraggable && !props.disabled && !!kanban && !!column)
function columnName(el: HTMLElement | null) {
return el?.getAttribute('aria-label') || el?.dataset.columnId || 'column'
}
/** Ordered, enabled columns of the board this card sits in. */
function boardColumns(): HTMLElement[] {
const board = cardEl.value?.closest('[data-slot="kanban"]') ?? document
return Array.from(board.querySelectorAll<HTMLElement>('[data-slot="kanban-column"]'))
}
function focusSelfAfterMove() {
// The consumer owns the data, so the card is re-rendered (often as a new
// node) in its new column. Re-find it by id and restore focus.
requestAnimationFrame(() => {
const moved = document.querySelector<HTMLElement>(`[data-slot="kanban-card"][data-card-id="${props.id}"]`)
moved?.focus()
})
}
function grab() {
if (!canKeyboardDrag.value) return
kanban!.setGrabbedCard(props.id)
kanban!.setDraggingCard(props.id, column!.columnId)
kanban!.setOverColumn(column!.columnId)
kanban!.announce(`Picked up card. Use the arrow keys to move it, space to drop, escape to cancel.`)
}
function release(cancelled: boolean) {
if (!kanban) return
kanban.setGrabbedCard(null)
kanban.setDraggingCard(null, null)
kanban.setOverColumn(null)
kanban.announce(cancelled ? 'Move cancelled.' : 'Card dropped.')
}
function moveToColumn(delta: -1 | 1) {
const columns = boardColumns()
const currentIdx = columns.findIndex((el) => el.dataset.columnId === column!.columnId)
if (currentIdx === -1) return
const target = columns[currentIdx + delta]
// Deliberately not wrapping: running off the end of a board should stop,
// not teleport the card back to the first column.
if (!target?.dataset.columnId) return
kanban!.emitMove({
cardId: props.id,
fromColumnId: column!.columnId,
toColumnId: target.dataset.columnId,
})
kanban!.setDraggingCard(props.id, target.dataset.columnId)
kanban!.setOverColumn(target.dataset.columnId)
kanban!.announce(`Moved to ${columnName(target)}.`)
focusSelfAfterMove()
}
function moveWithinColumn(delta: -1 | 1) {
const columnEl = cardEl.value?.closest<HTMLElement>('[data-slot="kanban-column"]')
if (!columnEl) return
const cards = Array.from(columnEl.querySelectorAll<HTMLElement>('[data-slot="kanban-card"]'))
const currentIdx = cards.findIndex((el) => el.dataset.cardId === props.id)
if (currentIdx === -1) return
const targetIdx = currentIdx + delta
if (targetIdx < 0 || targetIdx > cards.length - 1) return
kanban!.emitMove({
cardId: props.id,
fromColumnId: column!.columnId,
toColumnId: column!.columnId,
toIndex: targetIdx,
})
kanban!.announce(`Position ${targetIdx + 1} of ${cards.length}.`)
focusSelfAfterMove()
}
function handleKeydown(e: KeyboardEvent) {
if (!canKeyboardDrag.value) return
if (e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault()
if (isGrabbed.value) release(false)
else grab()
return
}
if (!isGrabbed.value) return
if (e.key === 'Escape') {
e.preventDefault()
release(true)
return
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
moveToColumn(e.key === 'ArrowLeft' ? -1 : 1)
return
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
moveWithinColumn(e.key === 'ArrowUp' ? -1 : 1)
}
}
function handleBlur() {
// A grabbed card that loses focus (click elsewhere, Tab) would otherwise
// stay stuck in the held state with no way back to it.
if (isGrabbed.value) release(true)
}
function handleDragStart(e: DragEvent) {
if (props.disabled) {
e.preventDefault()
return
}
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', props.id)
}
kanban?.setDraggingCard(props.id, column?.columnId ?? null)
}
function handleDragEnd() {
kanban?.setDraggingCard(null, null)
kanban?.setOverColumn(null)
}
</script>
<template>
<!-- `role="button"` rather than a real <button> so consumers can nest
interactive content (menus, links) inside a card — the HTML spec
forbids that inside <button>. Matches <BoardCard>. -->
<div
ref="cardEl"
data-uipkge
data-slot="kanban-card"
:data-card-id="id"
:data-state="isGrabbed ? 'grabbed' : isDragging ? 'dragging' : 'idle'"
:data-disabled="disabled || undefined"
:role="canKeyboardDrag ? 'button' : undefined"
:tabindex="canKeyboardDrag ? 0 : undefined"
:aria-disabled="disabled || undefined"
:aria-roledescription="canKeyboardDrag ? 'draggable card' : undefined"
:aria-pressed="canKeyboardDrag ? isGrabbed : undefined"
:draggable="!disabled"
:class="cn(kanbanCardVariants({ isDragging }), disabled && 'pointer-events-none opacity-50', props.class)"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
@keydown="handleKeydown"
@blur="handleBlur"
>
<slot :is-dragging="isDragging" :is-grabbed="isGrabbed" />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div data-slot="kanban-card-header" :class="cn('flex flex-col gap-1', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<p data-slot="kanban-card-title" :class="cn('text-foreground text-sm leading-snug font-medium', props.class)">
<slot />
</p>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<p data-slot="kanban-card-description" :class="cn('text-muted-foreground line-clamp-2 text-xs', props.class)">
<slot />
</p>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div
data-slot="kanban-card-footer"
:class="cn('text-muted-foreground mt-1 flex items-center justify-between gap-2 pt-1 text-xs', props.class)"
>
<slot />
</div>
</template>
import type { InjectionKey, Ref } from 'vue'
export interface KanbanMoveEvent {
cardId: string
fromColumnId: string
toColumnId: string
toIndex?: number
}
export interface KanbanContext {
draggingCardId: Ref<string | null>
draggingColumnId: Ref<string | null>
overColumnId: Ref<string | null>
/** Set while a card is held by keyboard (Space), not by pointer drag. */
grabbedCardId: Ref<string | null>
setDraggingCard: (cardId: string | null, columnId: string | null) => void
setOverColumn: (columnId: string | null) => void
setGrabbedCard: (cardId: string | null) => void
emitMove: (event: KanbanMoveEvent) => void
/** Speak a message through the board's polite live region. */
announce: (message: string) => void
}
export const KanbanContextKey: InjectionKey<KanbanContext> = Symbol('KanbanContext')
export interface KanbanColumnContext {
columnId: string
/** Column label, used to announce keyboard moves. Falls back to the id. */
label: Ref<string | undefined>
}
export const KanbanColumnContextKey: InjectionKey<KanbanColumnContext> = Symbol('KanbanColumnContext')
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const kanbanColumnVariants = cva(
'flex min-h-[300px] w-72 shrink-0 flex-col gap-2 rounded-xl border bg-muted/40 p-3 transition-colors duration-200 ease-out',
{
variants: {
isOver: {
true: 'border-primary/50 bg-primary/5 ring-2 ring-primary/20',
false: 'border-border/70',
},
},
defaultVariants: {
isOver: false,
},
},
)
export const kanbanCardVariants = cva(
'group relative flex cursor-grab flex-col gap-2 rounded-lg border bg-card p-3 text-card-foreground shadow-xs transition-[border-color,box-shadow,opacity] duration-150 ease-out active:cursor-grabbing hover:border-border hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
{
variants: {
isDragging: {
// Keyboard grab reuses this state, so the held card stays fully
// opaque — unlike a pointer drag there is no drag image to look at.
true: 'shadow-none ring-2 ring-primary/40 opacity-100 data-[state=dragging]:opacity-40',
false: 'opacity-100',
},
},
defaultVariants: {
isDragging: false,
},
},
)
export type KanbanColumnVariantsProps = VariantProps<typeof kanbanColumnVariants>
export type KanbanCardVariantsProps = VariantProps<typeof kanbanCardVariants>
export { default as Kanban } from './Kanban.vue'
export { default as KanbanBoard } from './KanbanBoard.vue'
export { default as KanbanColumn } from './KanbanColumn.vue'
export { default as KanbanColumnHeader } from './KanbanColumnHeader.vue'
export { default as KanbanColumnDot } from './KanbanColumnDot.vue'
export { default as KanbanColumnTitle } from './KanbanColumnTitle.vue'
export { default as KanbanColumnCount } from './KanbanColumnCount.vue'
export { default as KanbanColumnAdd } from './KanbanColumnAdd.vue'
export { default as KanbanColumnBody } from './KanbanColumnBody.vue'
export { default as KanbanColumnEmpty } from './KanbanColumnEmpty.vue'
export { default as KanbanCard } from './KanbanCard.vue'
export { default as KanbanCardHeader } from './KanbanCardHeader.vue'
export { default as KanbanCardTitle } from './KanbanCardTitle.vue'
export { default as KanbanCardDescription } from './KanbanCardDescription.vue'
export { default as KanbanCardFooter } from './KanbanCardFooter.vue'
export * from './kanban.variants'
export * from './context'
Raw manifest:https://uipkge.dev/r/vue/kanban.json