Display

Keyboard Card Move

A board where moving is keyboard-first: space picks a card up, arrows carry it across columns and inside one, space drops it and Escape cancels.

  • kanban
  • keyboard
  • a11y
  • board

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/kanban-006?lang=en

Space picks the card up, arrows carry it, space drops it, Escape cancels.

Бэклог3

В работе1

Готово1

Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "kanban-006" (Keyboard Card Move) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/kanban-006.json

Registry item: https://vibeui.ru/r/kanban-006.json
Installs to: components/vibeui/kanban-006.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A board where moving is keyboard-first: space picks a card up, arrows carry it across columns and inside one, space drops it and Escape cancels.

A board with keyboard moving: pick up with space, carry with arrows across columns and rows, drop or cancel, every step announced. Zero dependencies, one file.

## 3. How to use it
import { Kanban006 } from "@/components/vibeui/kanban-006"

<Kanban006 columns={["Backlog", "Doing"]} onChange={save} />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-kanban-006-* palette — do not swap it for your theme tokens
- the card as a button element: without it arrows and focus would need hand-made tabindex work
- the board snapshot taken on pick-up: without it Escape has nothing to restore
- the aria-live=assertive region: while moving blind, intermediate steps matter more than politeness
- the column and position in every message — "moved" without coordinates is useless
- the lifted card look via shadow and offset, not the border colour alone

## 6. You may change
- the columns and cards arrays
- hint — the text explaining the keys
- the onChange handler and the accent through the accent prop
- the column width

## 7. Rules
- Mouse dragging is deliberately absent: the block demonstrates the keyboard model in full.
- Losing focus drops the card: that guards against a stuck move mode.
- Moving to a neighbouring column appends the card — inside the column it is moved with up and down arrows.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/kanban-006.json
https://vibeui.ru/r/kanban-006.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-kanban-006-*. Клиентский: "use client" ради режима переноса. Карточка — button, поэтому фокус и клавиши достаются без tabindex. Пробел переключает захват и запоминает снимок доски, стрелки влево и вправо меняют колонку, вверх и вниз — место внутри колонки, Escape возвращает снимок. Живая область role=status с aria-live=assertive называет колонку и позицию после каждого шага, колонка «в руке» подсвечена.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useState } from "react"
import type {
  ComponentPropsWithoutRef,
  CSSProperties,
  KeyboardEvent,
} from "react"

export type Kanban006Card = {
  id: string
  title: string
  column: string
}

export type Kanban006Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  columns?: string[]
  cards?: Kanban006Card[]
  hint?: string
  onChange?: (cards: Kanban006Card[]) => void
  accent?: string
}

// Идея компонента: доска, где перенос сделан клавиатурой в первую очередь, а не
// как запасной путь. Модель та же, что у мыши: карточку берут (пробел), несут
// (стрелки: влево и вправо — колонка, вверх и вниз — место в колонке), кладут
// (пробел) или бросают (Escape, порядок возвращается к исходному). Пока
// карточка «в руке», у неё поднятый вид и aria-grabbed, а каждый шаг
// проговаривается в живой области: без озвучки перенос вслепую невозможен.
// Escape восстанавливает снимок доски, сделанный в момент захвата.
const STYLES = `
:where([data-vibeui-block="kanban-006"]){
--vibeui-kanban-006-bg:oklch(0.985 0.002 265);
--vibeui-kanban-006-card:oklch(1 0 0);
--vibeui-kanban-006-fg:oklch(0.24 0.014 265);
--vibeui-kanban-006-muted:oklch(0.56 0.014 265);
--vibeui-kanban-006-border:oklch(0.91 0.006 265);
--vibeui-kanban-006-accent:oklch(0.55 0.2 262);
--vibeui-kanban-006-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="kanban-006"]{
position:relative;display:grid;gap:0.625rem;
width:100%;box-sizing:border-box;padding:0.75rem;
background:var(--vibeui-kanban-006-bg);
border:1px solid var(--vibeui-kanban-006-border);border-radius:0.875rem;
font-family:var(--vibeui-kanban-006-font);color:var(--vibeui-kanban-006-fg);
}
[data-vibeui-block="kanban-006"] *{box-sizing:border-box}
[data-vibeui-block="kanban-006"] [data-part="hint"]{
margin:0;font-size:0.6875rem;line-height:1.4;color:var(--vibeui-kanban-006-muted);
}
[data-vibeui-block="kanban-006"] [data-part="board"]{
display:grid;grid-auto-flow:column;grid-auto-columns:minmax(10.5rem,1fr);gap:0.625rem;overflow-x:auto;
}
[data-vibeui-block="kanban-006"] [data-part="column"]{
display:flex;flex-direction:column;gap:0.5rem;min-width:0;
padding:0.5rem;border-radius:0.75rem;border:1px dashed transparent;
}
/* Колонка, в которой сейчас «рука», подсвечена: цель переноса должна быть видна. */
[data-vibeui-block="kanban-006"] [data-part="column"][data-active="true"]{
border-color:var(--vibeui-kanban-006-accent);
background:color-mix(in oklab,var(--vibeui-kanban-006-accent) 6%,transparent);
}
[data-vibeui-block="kanban-006"] [data-part="head"]{
margin:0;display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
font-size:0.75rem;font-weight:650;
}
[data-vibeui-block="kanban-006"] [data-part="count"]{
color:var(--vibeui-kanban-006-muted);font-size:0.6875rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="kanban-006"] ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:0.375rem}
[data-vibeui-block="kanban-006"] [data-part="card"]{
appearance:none;display:block;width:100%;text-align:left;cursor:pointer;
padding:0.5rem;border-radius:0.625rem;
background:var(--vibeui-kanban-006-card);
border:1px solid var(--vibeui-kanban-006-border);
box-shadow:0 1px 2px oklch(0.2 0.02 265 / 6%);
color:inherit;font:inherit;font-size:0.75rem;line-height:1.35;
transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;
}
[data-vibeui-block="kanban-006"] [data-part="card"]:focus-visible{outline:2px solid var(--vibeui-kanban-006-accent);outline-offset:2px}
/* Поднятая карточка отличается тенью и сдвигом, а не только цветом рамки. */
[data-vibeui-block="kanban-006"] [data-part="card"][aria-grabbed="true"]{
border-color:var(--vibeui-kanban-006-accent);
box-shadow:0 8px 18px oklch(0.2 0.02 265 / 16%);
transform:translateY(-2px);
}
[data-vibeui-block="kanban-006"] [data-part="place"]{
display:block;margin-top:0.25rem;
color:var(--vibeui-kanban-006-muted);font-size:0.625rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="kanban-006"] [data-part="live"]{
position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
clip-path:inset(50%);white-space:nowrap;border:0;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="kanban-006"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_COLUMNS = ["Бэклог", "В работе", "Готово"]

const DEFAULT_CARDS: Kanban006Card[] = [
  { id: "1", title: "Расписание рассылок", column: "Бэклог" },
  { id: "2", title: "Поиск по каталогу", column: "Бэклог" },
  { id: "3", title: "Карта складов", column: "Бэклог" },
  { id: "4", title: "Оплата частями", column: "В работе" },
  { id: "5", title: "Онбординг", column: "Готово" },
]

/**
 * Доска с переносом карточки клавиатурой: взять, нести стрелками, положить.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Kanban006({
  columns = DEFAULT_COLUMNS,
  cards = DEFAULT_CARDS,
  hint = "Пробел берёт карточку, стрелки несут, пробел кладёт, Escape отменяет.",
  onChange,
  accent,
  className,
  style,
  ...props
}: Kanban006Props) {
  const [board, setBoard] = useState(cards)
  const [grabbed, setGrabbed] = useState<string | null>(null)
  const [snapshot, setSnapshot] = useState<Kanban006Card[]>(cards)
  const [announcement, setAnnouncement] = useState("")

  const apply = (next: Kanban006Card[]) => {
    setBoard(next)
    onChange?.(next)
  }

  const place = (list: Kanban006Card[], id: string) => {
    const card = list.find((row) => row.id === id)!
    const column = list.filter((row) => row.column === card.column)
    return `«${card.title}»: колонка «${card.column}», позиция ${column.indexOf(card) + 1} из ${column.length}.`
  }

  const moveWithin = (id: string, step: -1 | 1) => {
    const card = board.find((row) => row.id === id)!
    const column = board.filter((row) => row.column === card.column)
    const from = column.indexOf(card)
    const to = from + step

    if (to < 0 || to >= column.length) {
      setAnnouncement(`Край колонки. ${place(board, id)}`)
      return
    }

    const next = [...board]
    const a = next.indexOf(column[from])
    const b = next.indexOf(column[to])
    ;[next[a], next[b]] = [next[b], next[a]]
    apply(next)
    setAnnouncement(place(next, id))
  }

  const moveAcross = (id: string, step: -1 | 1) => {
    const card = board.find((row) => row.id === id)!
    const target = columns[columns.indexOf(card.column) + step]

    if (!target) {
      setAnnouncement(`Край доски. ${place(board, id)}`)
      return
    }

    const rest = board.filter((row) => row.id !== id)
    const moved = { ...card, column: target }
    const last = rest.map((row) => row.column).lastIndexOf(target)
    const next = [...rest]
    next.splice(last + 1, 0, moved)
    apply(next)
    setAnnouncement(place(next, id))
  }

  const handleKey = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
    if (event.key === " " || event.key === "Enter") {
      event.preventDefault()

      if (grabbed === id) {
        setGrabbed(null)
        setAnnouncement(`Положено. ${place(board, id)}`)
      } else {
        setGrabbed(id)
        setSnapshot(board)
        setAnnouncement(`Взято. ${place(board, id)} Стрелки несут карточку.`)
      }

      return
    }

    if (event.key === "Escape" && grabbed === id) {
      event.preventDefault()
      apply(snapshot)
      setGrabbed(null)
      setAnnouncement(`Перенос отменён. ${place(snapshot, id)}`)
      return
    }

    if (grabbed !== id) return

    if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
      event.preventDefault()
      moveAcross(id, event.key === "ArrowLeft" ? -1 : 1)
    }

    if (event.key === "ArrowUp" || event.key === "ArrowDown") {
      event.preventDefault()
      moveWithin(id, event.key === "ArrowUp" ? -1 : 1)
    }
  }

  const held = board.find((row) => row.id === grabbed)

  const palette = {
    ...(accent ? { "--vibeui-kanban-006-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-kanban-006" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="kanban-006"
        className={className}
        style={palette}
      >
        <p data-part="hint">{hint}</p>
        <div data-part="board">
          {columns.map((column) => {
            const rows = board.filter((card) => card.column === column)

            return (
              <section
                key={column}
                data-part="column"
                data-active={held?.column === column}
                aria-label={`${column}: ${rows.length}`}
              >
                <p data-part="head">
                  {column}
                  <span data-part="count">{rows.length}</span>
                </p>
                <ul>
                  {rows.map((card, index) => (
                    <li key={card.id}>
                      <button
                        type="button"
                        data-part="card"
                        aria-grabbed={card.id === grabbed}
                        aria-label={`${card.title}, колонка «${column}», позиция ${index + 1} из ${rows.length}`}
                        onKeyDown={(event) => handleKey(event, card.id)}
                        onBlur={() => {
                          if (grabbed === card.id) setGrabbed(null)
                        }}
                      >
                        {card.title}
                        <span data-part="place" aria-hidden="true">
                          {index + 1} / {rows.length}
                        </span>
                      </button>
                    </li>
                  ))}
                </ul>
              </section>
            )
          })}
        </div>
        <span data-part="live" role="status" aria-live="assertive">
          {announcement}
        </span>
      </div>
    </>
  )
}