Skip to content
edmi-ui

Kanban Column

Sunken stage column with a mono header and Card items.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval
Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval
Thesis stage 1/1
Initial thesis
User task
Mandate stage 0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval
Terminal window
npx shadcn@latest add @edmi-ui/kanban-column
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/kanban-column
Terminal window
yarn dlx shadcn@latest add @edmi-ui/kanban-column
Terminal window
bunx shadcn@latest add @edmi-ui/kanban-column

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/kanban-column
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/kanban-column
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/kanban-column
Terminal window
bunx shadcn-vue@latest add @edmi-ui/kanban-column

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/kanban-column.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/kanban-column.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/kanban-column.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/kanban-column.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
KanbanColumn,
KanbanItem,
} from "@/components/kanban-column/kanban-column";
<div className="flex flex-wrap items-start gap-3">
<KanbanColumn title="Thesis stage" meta="1/1">
<KanbanItem
title="Initial thesis"
description="User task"
/>
</KanbanColumn>
<KanbanColumn title="Mandate stage" meta="0/3">
<KanbanItem
disabled
title="Pick tokens + weights"
description="Agent task"
/>
<KanbanItem
disabled
title="Review mandate"
description="Agent requests approval"
/>
</KanbanColumn>
</div>
<script setup lang="ts">
import { KanbanColumn, KanbanItem } from "@/components/ui/kanban-column";
</script>
<template>
<div class="flex flex-wrap items-start gap-3">
<KanbanColumn title="Thesis stage" meta="1/1">
<KanbanItem title="Initial thesis" description="User task">
<template #icon><SparklesIcon /></template>
</KanbanItem>
</KanbanColumn>
<KanbanColumn title="Mandate stage" meta="0/3">
<KanbanItem disabled title="Pick tokens + weights" description="Agent task">
<template #icon><SparklesIcon /></template>
</KanbanItem>
<KanbanItem disabled title="Review mandate" description="Agent requests approval">
<template #icon><SparklesIcon /></template>
</KanbanItem>
</KanbanColumn>
</div>
</template>
<script lang="ts">
import * as Kanban from "$lib/components/ui/kanban-column";
</script>
<div class="flex flex-wrap items-start gap-3">
<Kanban.Root title="Thesis stage" meta="1/1">
<Kanban.Item title="Initial thesis" description="User task" />
</Kanban.Root>
<Kanban.Root title="Mandate stage" meta="0/3">
<Kanban.Item disabled title="Pick tokens + weights" description="Agent task" />
<Kanban.Item disabled title="Review mandate" description="Agent requests approval" />
</Kanban.Root>
</div>
registry/blocks/kanban-column/kanban-column.tsx
"use client";
import { cn } from "cn";
import * as React from "react";
import { ArrowUpRightIcon } from "@phosphor-icons/react";
import { Card } from "@/registry/edmi/ui/card";
import type { Elevation } from "@/registry/edmi/ui/elevation";
type KanbanColumnContextValue = { elevation?: Elevation };
/** Depth handed from a column to its items (an item's own `elevation` wins). */
const KanbanColumnContext = React.createContext<KanbanColumnContextValue>({});
type KanbanColumnProps = Omit<React.ComponentProps<"div">, "title"> & {
title: React.ReactNode;
/** Right header meta, e.g. "1/3". */
meta?: React.ReactNode;
/** ✦ depth of every `KanbanItem` (items may override). */
elevation?: Elevation;
};
/** Sunken stage column holding `KanbanItem` cards. */
function KanbanColumn({
className,
title,
meta,
elevation,
children,
...props
}: KanbanColumnProps) {
const ctx = React.useMemo(() => ({ elevation }), [elevation]);
return (
<div
data-slot="kanban-column"
className={cn(
"flex w-[282px] flex-col gap-2 rounded-lg border border-sk-bd bg-sk-bg p-2.5 shadow-sunken",
className,
)}
{...props}
>
<div className="flex items-center justify-between px-1 pt-0.5 pb-1 font-mono text-[11px] text-muted-foreground">
<span>{title}</span>
{meta ? <span>{meta}</span> : null}
</div>
<KanbanColumnContext.Provider value={ctx}>
{children}
</KanbanColumnContext.Provider>
</div>
);
}
type KanbanItemProps = Omit<React.ComponentProps<typeof Card>, "title"> & {
title: React.ReactNode;
description?: React.ReactNode;
/** Leading icon element (an `IconPlaceholder`). */
icon?: React.ReactNode;
/** Trailing element; defaults to an arrow up-right. */
action?: React.ReactNode;
/** Dims the card (not yet reachable). */
disabled?: boolean;
};
function KanbanItem({
className,
title,
description,
icon,
action,
disabled,
elevation,
...props
}: KanbanItemProps) {
const column = React.useContext(KanbanColumnContext);
return (
<Card
data-slot="kanban-item"
elevation={elevation ?? column.elevation}
data-disabled={disabled ? "" : undefined}
className={cn(
"flex-row items-center gap-2.5 px-3 py-2.5 data-[disabled]:opacity-60",
className,
)}
{...props}
>
{icon ? (
<span className="grid size-7 shrink-0 place-items-center rounded-lg border border-border bg-muted [&_svg]:size-3.5">
{icon}
</span>
) : null}
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium">{title}</div>
{description ? (
<div className="text-[11px] text-muted-foreground">{description}</div>
) : null}
</div>
<span className="text-muted-foreground">
{action ?? (
<ArrowUpRightIcon
className="size-[13px]"
/>
)}
</span>
</Card>
);
}
export { KanbanColumn, KanbanItem };
registry/ui/kanban-column/KanbanColumn.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import type { Elevation } from '@/registry/edmi/ui/elevation'
import { KANBAN_COLUMN_KEY } from './context'
const props = defineProps<{
/** ✦ depth of every `KanbanItem` (items may override) */
elevation?: Elevation
class?: HTMLAttributes['class']
title: string
/** Right header meta, e.g. "1/3". */
meta?: string
}>()
provide(KANBAN_COLUMN_KEY, {
get elevation() { return props.elevation },
})
</script>
<template>
<!-- Sunken stage column holding `KanbanItem` cards. -->
<div
data-slot="kanban-column"
:class="cn('flex w-[282px] flex-col gap-2 rounded-lg border border-sk-bd bg-sk-bg p-2.5 shadow-sunken', props.class)"
>
<div class="flex items-center justify-between px-1 pt-0.5 pb-1 font-mono text-[11px] text-muted-foreground">
<span>{{ title }}</span>
<span v-if="meta">{{ meta }}</span>
</div>
<slot />
</div>
</template>
registry/ui/kanban-column/context.ts
import type { InjectionKey } from "vue";
import type { Elevation } from "@/registry/edmi/ui/elevation";
export type KanbanColumnContext = { readonly elevation?: Elevation };
/** Depth handed from a `KanbanColumn` to its `KanbanItem`s (an item's own `elevation` wins). */
export const KANBAN_COLUMN_KEY: InjectionKey<KanbanColumnContext> =
Symbol("EDMI_KANBAN_COLUMN");
registry/ui/kanban-column/KanbanItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { ArrowUpRightIcon } from '@lucide/vue'
import { inject } from 'vue'
import { Card } from '@/registry/edmi/ui/card'
import { cn } from '@/registry/edmi/lib/utils'
import type { Elevation } from '@/registry/edmi/ui/elevation'
import { KANBAN_COLUMN_KEY } from './context'
const props = withDefaults(defineProps<{
/** ✦ depth of this card; defaults to the column's `elevation` */
elevation?: Elevation
class?: HTMLAttributes['class']
title: string
description?: string
/** Dims the card (not yet reachable). */
disabled?: boolean
}>(), { elevation: undefined })
const column = inject(KANBAN_COLUMN_KEY, null)
// Slots: `icon` (leading), `action` (trailing; defaults to an arrow up-right).
</script>
<template>
<Card
:elevation="props.elevation ?? column?.elevation"
data-slot="kanban-item"
:data-disabled="disabled ? '' : undefined"
:class="cn('flex-row items-center gap-2.5 px-3 py-2.5 data-[disabled]:opacity-60', props.class)"
>
<span
v-if="$slots.icon"
class="grid size-7 shrink-0 place-items-center rounded-lg border border-border bg-muted [&_svg]:size-3.5"
>
<slot name="icon" />
</span>
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium">{{ title }}</div>
<div v-if="description" class="text-[11px] text-muted-foreground">{{ description }}</div>
</div>
<span class="text-muted-foreground">
<slot name="action"><ArrowUpRightIcon class="size-[13px]" /></slot>
</span>
</Card>
</template>
registry/ui/kanban-column/index.ts
export { KANBAN_COLUMN_KEY, type KanbanColumnContext } from "./context";
export { default as KanbanColumn } from "./KanbanColumn.vue";
export { default as KanbanItem } from "./KanbanItem.vue";
src/lib/registry/ui/kanban-column/index.ts
import Root from "./kanban-column.svelte";
import Item from "./kanban-item.svelte";
export {
Item,
Item as KanbanItem,
Root,
//
Root as KanbanColumn,
};
src/lib/registry/ui/kanban-column/kanban-column.svelte
<script lang="ts" module>
import { getContext, setContext } from "svelte";
/** Depth handed from a `KanbanColumn` to its `KanbanItem`s (an item's own `elevation` wins). */
export const KANBAN_COLUMN_CONTEXT = Symbol("EDMI_KANBAN_COLUMN");
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
export function setKanbanColumnCtx(ctx: { elevation: Elevation | undefined }) {
setContext(KANBAN_COLUMN_CONTEXT, ctx);
}
export function getKanbanColumnCtx() {
return getContext<{ elevation: Elevation | undefined } | undefined>(KANBAN_COLUMN_CONTEXT);
}
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
title,
meta,
elevation = "auto",
children,
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "title" | "children">> & {
title: string;
/** Right header meta, e.g. "1/3". */
meta?: string;
/** ✦ depth of every `KanbanItem` (items may override). */
elevation?: Elevation;
children?: Snippet;
} = $props();
setKanbanColumnCtx({
get elevation() {
return elevation === "auto" ? undefined : elevation;
},
});
</script>
<!-- Sunken stage column holding `KanbanItem` cards. -->
<div
bind:this={ref}
data-slot="kanban-column"
class={cn(
"flex w-[282px] flex-col gap-2 rounded-lg border border-sk-bd bg-sk-bg p-2.5 shadow-sunken",
className
)}
{...restProps}
>
<div class="flex items-center justify-between px-1 pt-0.5 pb-1 font-mono text-[11px] text-muted-foreground">
<span>{title}</span>
{#if meta}<span>{meta}</span>{/if}
</div>
{@render children?.()}
</div>
src/lib/registry/ui/kanban-column/kanban-item.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Card } from "$lib/registry/ui/card/index.js";
import ArrowUpRightIcon from "phosphor-svelte/lib/ArrowUpRight";
import type { ComponentProps, Snippet } from "svelte";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import { getKanbanColumnCtx } from "./kanban-column.svelte";
let {
ref = $bindable(null),
class: className,
title,
description,
icon,
action,
disabled,
elevation = "auto",
...restProps
}: Omit<ComponentProps<typeof Card>, "title" | "children"> & {
title: string;
description?: string;
/** Leading icon (an `IconPlaceholder`). */
icon?: Snippet;
/** Trailing element; defaults to an arrow up-right. */
action?: Snippet;
/** Dims the card (not yet reachable). */
disabled?: boolean;
/** ✦ depth of this card; defaults to the column's `elevation`. */
elevation?: Elevation;
} = $props();
const ctx = getKanbanColumnCtx();
</script>
<Card
bind:ref
data-slot="kanban-item"
elevation={elevation !== "auto" ? elevation : ctx?.elevation}
data-disabled={disabled ? "" : undefined}
class={cn("flex-row items-center gap-2.5 px-3 py-2.5 data-[disabled]:opacity-60", className)}
{...restProps}
>
{#if icon}
<span class="grid size-7 shrink-0 place-items-center rounded-lg border border-border bg-muted [&_svg]:size-3.5">
{@render icon()}
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium">{title}</div>
{#if description}
<div class="text-[11px] text-muted-foreground">{description}</div>
{/if}
</div>
<span class="text-muted-foreground">
{#if action}
{@render action()}
{:else}
<ArrowUpRightIcon
class="size-[13px]"
/>
{/if}
</span>
</Card>

See the Elevation guide for the levels, roles and layered mode.

Flat by default. The column is a sunken well. elevation on the column flows to every KanbanItem card (items may override): raised (+1) bevel, floating (+2) bevel plus drop.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Raised (+1)

Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Floating (+2)

Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Flat (0)

Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Raised (+1)

Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Floating (+2)

Thesis stage1/1
Initial thesis
User task
Mandate stage0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Flat (0)

Thesis stage 1/1
Initial thesis
User task
Mandate stage 0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Raised (+1)

Thesis stage 1/1
Initial thesis
User task
Mandate stage 0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval

Floating (+2)

Thesis stage 1/1
Initial thesis
User task
Mandate stage 0/3
Pick tokens + weights
Agent task
Review mandate
Agent requests approval
Part Prop Type Default
KanbanColumn title ReactNode (Vue/Svelte: string) required
meta same as title; right header meta, e.g. 1/3 none
elevation ✦ "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
…props div props, sunken container 282px wide
KanbanItem title ReactNode (Vue/Svelte: string) required
description same as title none
icon React ReactNode; Vue slot icon; Svelte icon snippet none
action React ReactNode; Vue slot action; Svelte action snippet arrow up-right
disabled boolean; dims to 60% false
elevation ✦ "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • Whole block is an Edmi addition (DESIGN §5 Patterns, board 12). Composed from: card.
  • Install: shadcn add @edmi-ui/kanban-column.