← ui

drag-list

ui

a list a person reorders by carrying its cards

npx shadcn add @ag/drag-list
Already installed? Preview an update
npx shadcn add @ag/drag-list --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-04
  • New: a list a person reorders by carrying its cards, swift-utils' InkDragList on the web, on carry: one Tab stop, the arrows walking the cards, space carrying, return opening, delete deleting; allows answers true or the reason a move is refused, which the carried card says in the bad tone while over that place; a list in a page scrolls the page under the card. Controlled: onMove({ item, from, to }).
  • Its items are cards with item-card's parts, no longer a tinted fill and a coloured edge: renderItem writes the parts, tint is the card's hue by the flare rule, material draws the list plain or as a pane of glass with its cards inset on it, and detail opens an item whole (a press that does not travel, or return; esc, a press outside or its ✕ put it back). Its glance fits its tile.
Updated 2026-11-04 · release notes

shelf controls

uses carry, item-card, card, material

used by kanban

the demo above, verbatim

drag-list.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Backdrop } from "@/registry/base-nova/ui/backdrop"
import { CardFooter } from "@/registry/base-nova/ui/card"
import { DragList, type DragListMove } from "@/registry/base-nova/ui/drag-list"
import { CardAuthor, CardKind } from "@/registry/base-nova/ui/item-card"
import {
  type Material,
  MaterialProvider,
} from "@/registry/base-nova/ui/material"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"

// #region checklist
// A release checklist, put in order by carrying its steps, each a card of its kind's
// hue with who does it. Every step goes anywhere. A step pressed without moving opens whole.
type Step = {
  id: string
  title: string
  kind: "build" | "check" | "ship"
  who: string
  face?: string
  note: string
}
const STEPS: Step[] = [
  {
    id: "notes",
    title: "write the release notes",
    kind: "check",
    who: "Adrian Galilea",
    face: "/adriangalilea.jpg",
    note: "What changed for the person using it, in their words.",
  },
  {
    id: "bump",
    title: "bump the version",
    kind: "build",
    who: "Ada Lovelace",
    note: "The version comes from the commits since the last release.",
  },
  {
    id: "build",
    title: "build every platform",
    kind: "build",
    who: "Ada Lovelace",
    note: "The Mac, the phone, the TV: each from the same commit.",
  },
  {
    id: "smoke",
    title: "smoke test on a phone",
    kind: "check",
    who: "Walter Cronkite",
    face: "/people/cronkite.jpg",
    note: "Install it, open it, play a minute of something.",
  },
  {
    id: "ship",
    title: "ship it",
    kind: "ship",
    who: "Adrian Galilea",
    face: "/adriangalilea.jpg",
    note: "Once every check is done.",
  },
]
const HUE: Record<Step["kind"], string> = {
  build: "oklch(68% 0.13 250)",
  check: "oklch(72% 0.14 155)",
  ship: "oklch(70% 0.16 40)",
}

/** The consumer's own move: the list hands it over, the consumer writes it. */
function moved<T>(items: readonly T[], { from, to }: DragListMove<T>): T[] {
  const rest = items.filter((_, i) => i !== from)
  return [...rest.slice(0, to), items[from] as T, ...rest.slice(to)]
}

function Checklist({ material }: { material: Material }) {
  const [steps, setSteps] = React.useState(STEPS)
  return (
    <DragList
      label="release checklist"
      material={material}
      className="w-full max-w-sm"
      items={steps}
      itemId={(s) => s.id}
      name={(s) => s.title}
      tint={(s) => HUE[s.kind]}
      onMove={(move) => setSteps((all) => moved(all, move))}
      onDelete={(s) => setSteps((all) => all.filter((x) => x.id !== s.id))}
      renderItem={(s) => (
        <>
          <CardKind>{s.kind}</CardKind>
          <p className="font-semibold text-sm">{s.title}</p>
          <CardFooter>
            <CardAuthor name={s.who} src={s.face} />
          </CardFooter>
        </>
      )}
      detail={(s) => (
        <>
          <CardKind>{s.kind}</CardKind>
          <p className="text-pretty text-sm text-current/70">{s.note}</p>
          <CardFooter>
            <CardAuthor name={s.who} src={s.face} />
          </CardFooter>
        </>
      )}
    />
  )
}

/** Plain on the page, or glass over one of two pictures, each with light and dark
 *  in it: a valley of dark trees under a pale cliff, a snowfield under a blue sky. */
const LOOKS = {
  plain: null,
  valley: "/lightbox/card.jpg",
  snow: "/lightbox/tall.jpg",
} as const
type Look = keyof typeof LOOKS

function Looks() {
  const [look, setLook] = React.useState<Look>("plain")
  const picture = LOOKS[look]
  return (
    <div className="w-full space-y-4">
      <ToggleGroup
        size="sm"
        aria-label="material"
        value={look}
        onValueChange={(v) => setLook(v as Look)}
      >
        <ToggleGroupItem value="plain">plain</ToggleGroupItem>
        <ToggleGroupItem value="valley">glass, valley</ToggleGroupItem>
        <ToggleGroupItem value="snow">glass, snow</ToggleGroupItem>
      </ToggleGroup>
      {picture === null ? (
        <Checklist material="plain" />
      ) : (
        <MaterialProvider material="glass">
          <Backdrop src={picture} className="w-full rounded-3xl p-6">
            <Checklist material="glass" />
          </Backdrop>
        </MaterialProvider>
      )}
    </div>
  )
}
// #endregion

// #region rule
// A rule, said where it bites: ship stays last. Carry a step below it, or ship above
// any step: the card wears the bad tone and says why; let go there, it flies home and
// leans toward where it was asked to go.
const LAST = ["tag the release", "upload the build", "ship it"]

function ShipLast() {
  const [steps, setSteps] = React.useState(LAST)
  const ship = (all: readonly string[]) => all.length - 1
  return (
    <DragList
      label="release steps, ship last"
      className="w-full max-w-xs"
      items={steps}
      itemId={(s) => s}
      name={(s) => s}
      allows={({ item, to }) =>
        item === "ship it" || to === ship(steps)
          ? "ship stays the last step"
          : true
      }
      onMove={(move) => setSteps((all) => moved(all, move))}
      renderItem={(s) => <p className="text-sm">{s}</p>}
    />
  )
}
// #endregion

// #region page
// A long list in the page: the edge drive scrolls the page itself under the card.
const MANY = Array.from({ length: 24 }, (_, i) => ({
  id: `t${i}`,
  title: `track ${String(i + 1).padStart(2, "0")}`,
}))

function InThePage() {
  const [tracks, setTracks] = React.useState(MANY)
  return (
    <DragList
      label="tracks"
      className="w-full max-w-sm"
      items={tracks}
      itemId={(t) => t.id}
      name={(t) => t.title}
      onMove={(move) => setTracks((all) => moved(all, move))}
      renderItem={(t) => <p className="text-sm tabular-nums">{t.title}</p>}
    />
  )
}
// #endregion

export default function DragListDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="checklist"
        glance="large"
        title="a checklist, carried into order"
        description="Press and move a step (a finger: hold it first), or Tab in and press space, move it with the arrows, space to put it down, esc to put it back. A step pressed without moving, or Enter, opens it whole; delete takes it out. Plain or glass."
        with="checklist"
      >
        <Looks />
      </Sample>
      <Sample
        name="rule"
        title="a rule, and its refusal said"
        description="allows answers true or the reason: carry a step below ship, or ship anywhere."
        with="checklist rule"
      >
        <ShipLast />
      </Sample>
      <Sample
        name="page"
        title="a long list in the page"
        description="Carry a track toward the window's top or bottom edge: the page scrolls under it, slowly at the zone's edge, faster deeper in, and stops the moment the card leaves the zone."
        with="page"
      >
        <InThePage />
      </Sample>
    </div>
  )
}

the source, every file an install writes

drag-list.tsx
"use client"

// A LIST A PERSON REORDERS by carrying its items, swift-utils' InkDragList on the
// web: each item a `card` on the list's ground, taken up by a press (a mouse once it
// moves, a finger once held), carried with life (it grows a touch from under the
// press, throws its shadow, leans into its travel), the others gliding apart where it
// would land, put down on a spring; near the list's ends (or the page's, for a list in
// a page) the edge drive scrolls under it. The keys carry too: space takes the focused
// card up, the arrows move it, space or return puts it down, esc puts it back; the
// arrows walk the cards otherwise, return opens one, delete deletes it. The whole list
// is one Tab stop. ui/carry is the engine; this is a list's face on it.
//
// A card's look is `card`'s: its hue by the flare rule (a kind's), its parts written
// by `renderItem` (`CardFooter` from card, `CardKind`, `CardAuthor`, `CardCount` from
// item-card). Plain, the
// list is a ground of the fill ladder and its cards the page's; glass, the list is a
// pane of glass and its cards stand on it inset (no glass on glass). A press that never
// moved opens a card: whole (`detail`, the card grown out of its place into one of its
// own), or wherever `onOpen` goes.
//
// CONTROLLED: `onMove` hands the move over and the consumer writes its items; a move
// `allows` refuses (its answer the reason) opens no gap, the carried card wears the bad
// tone with the reason under it while it is over that place, and let go there it flies
// home and leans toward where it was asked to go.

import * as React from "react"
import { cn } from "@/lib/utils"
import { Card } from "@/registry/base-nova/ui/card"
import {
  CarryLayer,
  HOLDABLE,
  KEYED_LIFT,
  useCarry,
} from "@/registry/base-nova/ui/carry"
import { CardView, useCardView } from "@/registry/base-nova/ui/item-card"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"

/** A move asked of `allows` or made: the item, where it was and where it lands
 *  (indices in the list as it is, the landing one counted without the item). */
export type DragListMove<T> = { item: T; from: number; to: number }

/** What carries a card (its handle: the focus, the press), round its card: its
 *  corners the card's, so the keys' lift and its ring follow them. */
export const HANDLE = cn(
  HOLDABLE,
  KEYED_LIFT,
  "cursor-grab rounded-[0.75rem] outline-none focus-visible:ring-2 focus-visible:ring-ring",
)

/** A lane's ground, plain: the region the cards stand in. */
const LANE = "rounded-xl bg-fill-group p-2"

export function DragList<T>({
  items,
  itemId,
  label,
  name,
  renderItem,
  onMove,
  allows,
  tint,
  detail,
  onOpen,
  onDelete,
  material,
  className,
}: {
  items: readonly T[]
  itemId: (item: T) => string
  /** The list's accessible name. */
  label: string
  /** What an item is called: said as it moves, and its title opened whole. */
  name: (item: T) => string
  /** What a card holds: its parts. */
  renderItem: (item: T) => React.ReactNode
  onMove: (move: DragListMove<T>) => void
  /** Whether a move is allowed: `true`, or the reason it is not ("ship stays last"),
   *  which the carried card says while over that place, in the bad tone, and leans
   *  home with when let go there (Ink's InkDragList refusal). A refusal always says
   *  why: one with no words left the card floating with nothing said. */
  allows?: (move: DragListMove<T>) => true | string
  /** A card's hue, a CSS colour (its kind's). */
  tint?: (item: T) => string | undefined
  /** What a card says opened whole: a press that never moved, or return, opens it. */
  detail?: (item: T) => React.ReactNode
  /** Without `detail`: where a press that never moved, or return, goes. */
  onOpen?: (item: T) => void
  /** Delete or backspace on the focused card; the consumer takes it out. */
  onDelete?: (item: T) => void
  material?: Material
  className?: string
}) {
  const m = useMaterial(material)
  const ids = React.useMemo(() => [items.map(itemId)], [items, itemId])
  const byId = React.useMemo(
    () => new Map(items.map((item) => [itemId(item), item])),
    [items, itemId],
  )
  const of = (id: string) => byId.get(id) as T
  const view = useCardView<string>()
  const carry = useCarry<string>({
    lanes: ids,
    refuses: allows
      ? (id, from, to) => {
          const answer = allows({
            item: of(id),
            from: from.index,
            to: to.index,
          })
          if (answer !== true && (typeof answer !== "string" || answer === ""))
            throw new Error(
              `DragList: allows answers true or the reason a move is refused, got ${JSON.stringify(answer)}`,
            )
          return answer === true ? false : answer
        }
      : undefined,
    onMove: (id, from, to) =>
      onMove({ item: of(id), from: from.index, to: to.index }),
    name: (id) => name(of(id)),
    onOpen: detail ? (id) => view.show(id) : onOpen && ((id) => onOpen(of(id))),
    onDelete: onDelete && ((id) => onDelete(of(id))),
  })
  const opened = view.opened === null ? null : byId.get(view.opened)
  const list = (
    <ul ref={carry.lane(0)} aria-label={label} className="flex flex-col gap-2">
      {items.map((item) => {
        const id = itemId(item)
        return (
          <li key={id} {...carry.item(id)} className={HANDLE}>
            <Card
              hue={tint?.(item)}
              padding="compact"
              interactive
              material={m}
              style={view.source(id)}
            >
              {renderItem(item)}
            </Card>
          </li>
        )
      })}
    </ul>
  )
  return (
    <div data-slot="drag-list" className={className}>
      {m === "glass" ? (
        <Card material="glass" bodyClassName="p-2">
          {list}
        </Card>
      ) : (
        <div className={LANE}>{list}</div>
      )}
      <CarryLayer
        carry={carry}
        className="rounded-[0.75rem]"
        render={(id) => (
          <Card
            hue={tint?.(of(id))}
            padding="compact"
            interactive
            material={m}
            lifted
          >
            {renderItem(of(id))}
          </Card>
        )}
      />
      {detail && opened !== undefined && opened !== null && (
        <CardView
          view={view}
          title={name(opened)}
          hue={tint?.(opened)}
          material={m}
        >
          {detail(opened)}
        </CardView>
      )}
    </div>
  )
}