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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
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);
}
if (status) status.textContent = moving.map((option) => option.text).join(", ");
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"));
});
source.addEventListener("dblclick", () => move(source, target, false));
target.addEventListener("dblclick", () => move(target, source, false));
root.closest("form")?.addEventListener("submit", () => {
for (const option of target.options) option.selected = true;
});
}
onReadyOnce('[data-slot="transfer-list"]', wire);
</script>
---
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>
---
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;
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.