Selectable list
Rows to pick one from, for master–detail screens: conversations, candidate matches, references. Up and Down move focus, so do Home and End, and they skip disabled rows. Enter, Space or a click selects. Moving focus does not select, so the detail loads only when the user asks. Tab enters at the selected row, or at the first one. Rows hold no interactive content: a screen reader reads each row as text, so a link or button in it cannot be reached.
import { Badge } from "@etched/reticle/badge";
import { SelectableList, type SelectableListItem } from "@etched/reticle/selectable-list";
const conversations: SelectableListItem[] = [
{ value: "ana", label: "Ana Ruiz", description: "Thanks, Thursday at 2 works for me.", avatar: { name: "Ana Ruiz" }, end: <Badge>2</Badge> },
{ value: "jose", label: "José Núñez", description: "Could you share more about the bring-up team?", avatar: { name: "José Núñez" }, end: <Badge>1</Badge> },
{ value: "lee", label: "Lee Park", description: "You: Sent the loop schedule for next week.", avatar: { name: "Lee Park" } },
{ value: "mira", label: "Mira Osei", description: "Not looking right now. Maybe after the tape-out.", avatar: { name: "Mira Osei" } },
{ value: "kai", label: "Kai Ito", description: "Happy to chat. Is the role in Cupertino?", avatar: { name: "Kai Ito" } },
];
export function Preview() {
return (
<div className="card" style={{ inlineSize: "18rem" }}>
<SelectableList aria-label="Conversations" items={conversations.slice(0, 3)} defaultValue="jose" />
</div>
);
}Installation
import { SelectableList } from "@etched/reticle/selectable-list";Examples
Conversations
Up and Down move focus; Enter, Space or a click opens the conversation. Moving focus does not open it.
Showing the conversation with Lee Park.
import { useState } from "react";
import { Badge } from "@etched/reticle/badge";
import { Text } from "@etched/reticle/text";
import { SelectableList, type SelectableListItem } from "@etched/reticle/selectable-list";
const conversations: SelectableListItem[] = [
{ value: "ana", label: "Ana Ruiz", description: "Thanks, Thursday at 2 works for me.", avatar: { name: "Ana Ruiz" }, end: <Badge>2</Badge> },
{ value: "jose", label: "José Núñez", description: "Could you share more about the bring-up team?", avatar: { name: "José Núñez" }, end: <Badge>1</Badge> },
{ value: "lee", label: "Lee Park", description: "You: Sent the loop schedule for next week.", avatar: { name: "Lee Park" } },
{ value: "mira", label: "Mira Osei", description: "Not looking right now. Maybe after the tape-out.", avatar: { name: "Mira Osei" } },
{ value: "kai", label: "Kai Ito", description: "Happy to chat. Is the role in Cupertino?", avatar: { name: "Kai Ito" } },
];
export function Conversations() {
const [open, setOpen] = useState<string | null>("lee");
const person = conversations.find((c) => c.value === open)?.label;
return (
<div className="grid">
<div className="card">
<SelectableList aria-label="Conversations" items={conversations} value={open} onValueChange={setOpen} />
</div>
<Text variant="secondary">Showing the conversation with {person}.</Text>
</div>
);
}Candidate matches
Nothing is selected yet, so Tab enters at the first row. Visible text names the list through aria-labelledby.
Is this one of your candidates?
import { Pill } from "@etched/reticle/pill";
import { Text } from "@etched/reticle/text";
import { SelectableList, type SelectableListItem } from "@etched/reticle/selectable-list";
const matches: SelectableListItem[] = [
{ value: "c-1042", label: "Ana Ruiz", description: "Staff engineer at Tenstorrent", avatar: { name: "Ana Ruiz" }, end: <Pill tone="green">94% match</Pill> },
{ value: "c-2210", label: "Ana Ruíz Ortega", description: "Design verification at Cerebras", avatar: { name: "Ana Ruíz Ortega" }, end: <Pill tone="amber">71% match</Pill> },
{ value: "c-0388", label: "A. Ruiz", description: "Referral from Sam Lee, 2024", avatar: { name: "A. Ruiz" }, end: <Pill>38% match</Pill> },
];
export function CandidateMatches() {
return (
<div className="grid">
<div className="card">
<Text id="matches-label" bold DANGEROUS_style={{ padding: "var(--et-space-2) var(--et-space-4) 0" }}>
Is this one of your candidates?
</Text>
<SelectableList aria-labelledby="matches-label" items={matches} />
</div>
</div>
);
}Disabled rows
A disabled row cannot be selected, and the arrow keys skip it. Say why in its description.
import { Pill } from "@etched/reticle/pill";
import { SelectableList, type SelectableListItem } from "@etched/reticle/selectable-list";
const references: SelectableListItem[] = [
{ value: "sam", label: "Sam Lee", description: "Manager at Groq, 2021–2024", end: <Pill tone="green">Received</Pill> },
{ value: "priya", label: "Priya Shah", description: "No email or phone yet", end: <Pill>To do</Pill>, disabled: true },
{ value: "kai", label: "Kai Ito", description: "Peer at Cerebras, 2019–2021", end: <Pill tone="purple">Scheduled</Pill> },
{ value: "tom", label: "Tom Weber", description: "Report at SambaNova, 2022–2024", end: <Pill tone="blue">Activating</Pill> },
];
export function DisabledRows() {
return (
<div className="grid">
<div className="card">
<SelectableList aria-label="References" items={references} defaultValue="sam" />
</div>
</div>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | SelectableListItem[] | – | The rows, in order. |
| value | string | null | – | The selected row, controlled. null selects none. |
| defaultValue | string | null | null | The first selected row, uncontrolled. |
| onValueChange | (value: string) => void | – | Called when Enter, Space or a click selects a row. Moving focus does not select. |
| aria-label | string | – | Names the list, for example "Conversations". |
| aria-labelledby | string | – | The id of visible text that names the list. |