Transfer List
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/transfer-listPlain-CSS theme — no build step
npx astrocraft-ui add forms/transfer-list --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/transfer-list --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/transfer-list --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/transfer-list --theme tailwind --bridge lumosLive demo
TransferList
Two native <select multiple>s, so multi-select, type-ahead and the arrow keys are the platform's. Double-click moves one item; the buttons move the selection or everything. What moved is announced by name — the one thing a screen reader user cannot see happen.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `TransferList.astro` | `transfer-list` `transfer-list-status` | — | — | — |
| `TransferListControls.astro` | `transfer-list-controls` `transfer-list-move` | — | — | — |
| `TransferListPane.astro` | `transfer-list-label` `transfer-list-pane` `transfer-list-select` | `data-side`: `source` · `target` | — | `[disabled]` |
Source
What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/transfer-list/TransferList.astro — headless primitive (see ../../README.md).
// Two lists and four buttons: choose some items on the left, move them to the right. Both lists are
// native <select multiple>s (see TransferListPane), so every bit of list behavior — multi-select,
// arrows, type-ahead, labelling, form submission — is the platform's, and this file is only the
// moving and the announcement.
//
// MOVING IS MOVING A DOM NODE. `target.append(option)` takes the <option> out of one select and puts
// it in the other; there is no model to keep in step with the view, because the view IS the model.
//
// SUBMISSION: a <select multiple> submits only its SELECTED options, which is wrong here — what the
// user moved across is the answer, whether or not it is still highlighted. So the script selects
// everything in the target pane as the form submits. That runs on `submit`, not on every move, so
// the target does not sit there looking entirely highlighted while you work.
//
// The status region names what moved. It says the labels and nothing else — no sentence built in
// JavaScript, so there is nothing here to translate.
//
// <TransferList label="Columns">
// <TransferListPane side="source" label="Available" options={available} />
// <TransferListControls />
// <TransferListPane side="target" label="Shown" name="columns" options={shown} />
// </TransferList>
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & { label?: string };
const { label = "Transfer list", class: className, ...rest } = Astro.props;
---
<div role="group" aria-label={label} data-slot="transfer-list" class={className} {...rest}>
<slot />
<p role="status" aria-live="polite" data-slot="transfer-list-status"></p>
</div>
<script>
import { onReadyOnce } from "../../_once";
function wire(root: HTMLElement) {
const source = root.querySelector<HTMLSelectElement>(
'[data-slot="transfer-list-select"][data-side="source"]',
);
const target = root.querySelector<HTMLSelectElement>(
'[data-slot="transfer-list-select"][data-side="target"]',
);
const status = root.querySelector<HTMLElement>('[data-slot="transfer-list-status"]');
if (!source || !target) return;
const move = (from: HTMLSelectElement, to: HTMLSelectElement, all: boolean) => {
const moving = [...from.options].filter(
(option) => !option.disabled && (all || option.selected),
);
if (moving.length === 0) return;
for (const option of moving) {
option.selected = false;
to.append(option);
}
// The labels, not a sentence about them: this is the one thing a screen reader user cannot
// see happen, and a list of names needs no grammar.
if (status) status.textContent = moving.map((option) => option.text).join(", ");
// `change` on both, because either list's contents are now different and a consumer watching
// one of them should not have to know which side the user pressed.
for (const select of [from, to]) select.dispatchEvent(new Event("change", { bubbles: true }));
};
root.addEventListener("click", (event) => {
const button = (event.target as Element | null)?.closest<HTMLElement>(
'[data-slot="transfer-list-move"]',
);
const action = button?.dataset.action;
if (!action) return;
const adding = action.startsWith("add");
move(adding ? source : target, adding ? target : source, action.endsWith("all"));
});
// Double-click is the shortcut everyone tries on a list like this; it moves that one item.
source.addEventListener("dblclick", () => move(source, target, false));
target.addEventListener("dblclick", () => move(target, source, false));
// Everything in the target is the answer, selected or not — see the header.
root.closest("form")?.addEventListener("submit", () => {
for (const option of target.options) option.selected = true;
});
}
onReadyOnce('[data-slot="transfer-list"]', wire);
</script>
---
// src/components/ui/forms/transfer-list/TransferListControls.astro — TransferList compound part
// (see ../../README.md). The four move buttons, between the two panes.
//
// They are buttons and not drag handles because dragging between two lists is a pointer-only gesture
// with no keyboard equivalent anyone can discover. The buttons are the accessible route, and they
// work for everyone — which is why the library ships these and no drag at all.
//
// Every label is a prop: these are icon buttons, and an icon button with no accessible name is a
// button that announces as "button". Translate them by passing them.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
addLabel?: string;
removeLabel?: string;
addAllLabel?: string;
removeAllLabel?: string;
};
const {
addLabel = "Add selected",
removeLabel = "Remove selected",
addAllLabel = "Add all",
removeAllLabel = "Remove all",
class: className,
...rest
} = Astro.props;
const BUTTONS = [
{ action: "add", label: addLabel, glyph: "m9 18 6-6-6-6" },
{ action: "remove", label: removeLabel, glyph: "m15 18-6-6 6-6" },
{ action: "add-all", label: addAllLabel, glyph: "m7 18 6-6-6-6M13 18l6-6-6-6" },
{ action: "remove-all", label: removeAllLabel, glyph: "m17 18-6-6 6-6M11 18l-6-6 6-6" },
] as const;
---
<div role="group" data-slot="transfer-list-controls" class={className} {...rest}>
{
BUTTONS.map((button) => (
<button
type="button"
aria-label={button.label}
data-slot="transfer-list-move"
data-action={button.action}
>
<slot name="icon">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d={button.glyph} />
</svg>
</slot>
</button>
))
}
</div>
---
// src/components/ui/forms/transfer-list/TransferListPane.astro — TransferList compound part (see ../../README.md).
// One side of the transfer, and it is a NATIVE <select multiple>. That single choice is most of this
// primitive: a multi-select is already a listbox, already multi-selectable with Shift and Ctrl,
// already keyboard-operable with the arrows and type-ahead, already labelled by its <label>, and —
// the part a hand-built div listbox never manages — already a form control that submits.
//
// `size` makes it a list box rather than a dropdown; below about 4 it becomes a scroller too small
// to drag into.
//
// The `target` pane is the one that carries `name`. See TransferList.astro for how everything in it
// gets submitted, selected or not.
import type { HTMLAttributes } from "astro/types";
interface TransferOption {
value: string;
label: string;
disabled?: boolean;
}
type Props = HTMLAttributes<"select"> & {
side?: "source" | "target";
options?: readonly TransferOption[];
label?: string;
name?: string;
/** Rows shown at once — what makes it a list rather than a dropdown. */
size?: number;
};
const {
side = "source",
options = [],
label,
name,
size = 8,
class: className,
...rest
} = Astro.props;
const id = `transfer-${side}-${crypto.randomUUID().slice(0, 8)}`;
---
<div data-slot="transfer-list-pane" data-side={side}>
{
label && (
<label for={id} data-slot="transfer-list-label">
{label}
</label>
)
}
<select
id={id}
multiple
size={size}
name={name}
data-slot="transfer-list-select"
data-side={side}
class={className}
{...rest}
>
{
options.map((option) => (
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
))
}
</select>
</div>
import TransferList from "./TransferList.astro";
import TransferListControls from "./TransferListControls.astro";
import TransferListPane from "./TransferListPane.astro";
export { TransferList, TransferListControls, TransferListPane };
export default TransferList;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/transfer-list — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.