TierList
Ranked tier rows with drag-and-drop and keyboard sorting.
Labelled tier rows (S / A / B / C / D by default, any list of `{ id, label, color? }`) plus an unranked tray, holding one tile per item. Tiles move by pointer drag — between rows and within one, live-reordering with a ghost under the pointer — and by keyboard (Space picks up, arrows move, Space drops, Escape cancels), with every step read out by a polite live region, so ranking never depends on dragging (WCAG 2.2 SC 2.5.7). The component is presentational: what a tile shows is your `tile` snippet (a poster, an Avatar, a Card), and the result comes back as `placements` — tier id → ordered item ids. Row accents are palette slots (accent, cyan, yellow, muted, red, …) rendered as Badge-style washes; sizes read the `--ss-tier-*` tokens.
Demo
Usage
<script>
import { TierList } from 'dssoca';
const items = [
{ id: 'sonic', label: 'Sonic' },
{ id: 'chrono', label: 'Chrono Trigger' },
{ id: 'tetris', label: 'Tetris' },
];
let placements = $state({ S: ['chrono'] });
</script>
<TierList {items} bind:placements onchange={(p) => save(p)} label="Games" />
<!-- custom tiles + a details action on Enter / click -->
<TierList {items} bind:placements onselect={(item) => open(item)}>
{#snippet tile(item)}
<img src={posters[item.id]} alt="" />
{/snippet}
</TierList>
<!-- display only -->
<TierList {items} placements={theirs} readonly />Keyboard: every tile is a real `<button>` named after its item with `aria-describedby` instructions and `aria-pressed` while picked up; the live region (`role="status"`) reads "Picked up Alpha, S, position 1 of 3…", each move, the drop and a cancel. Left/Right step within the row (no wrap — the edges mean something), Home/End jump, Up/Down move to the neighbouring row keeping the position, and leaving the tile (Tab, a click elsewhere) cancels. Pointer: tiles set `touch-action: none` so a finger drags instead of scrolling the page; a drag starts after 4px, previews live and commits on release; `pointercancel` restores. Data: `placements` is the whole truth — keep it and re-render, or `bind:` it. Tile size is square by default (`--ss-tier-tile-w/h`); posters want `style="--ss-tier-tile-h: 162px"` on the root (1.5×). Zero radius; no colour literals — row washes are `color-mix()` of the slot.
HTML
interactive with vanilla.js The exact markup the component renders, generated at build time. Use it with dssoca/vanilla.css+ dssoca/vanilla.js — see Plain HTML & CSS.
<div class="ss-tierlist" role="group" aria-label="Games">
<p class="sr-only" id="ss-tier-tier-list-s1-hint">Press Space to pick up the tile, then the arrow keys to move it between positions and tiers, Space again to drop it, or Escape to cancel.</p>
<div class="live sr-only" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="row" data-tier="S" style="--ss-tier-color: var(--ss-accent);">
<div class="letter" id="ss-tier-tier-list-s1-S">S</div>
<ul class="zone" data-zone="S" aria-label="S">
<li class="cell" data-item="chrono">
<button type="button" class="tile" data-item="chrono" aria-label="Chrono Trigger" aria-describedby="ss-tier-tier-list-s1-hint" aria-pressed="false" aria-roledescription="draggable tile">
<span class="name">Chrono Trigger</span>
</button>
</li>
</ul>
</div>
<div class="row" data-tier="A" style="--ss-tier-color: var(--ss-cyan);">
<div class="letter" id="ss-tier-tier-list-s1-A">A</div>
<ul class="zone" data-zone="A" aria-label="A">
<li class="cell" data-item="tetris">
<button type="button" class="tile" data-item="tetris" aria-label="Tetris" aria-describedby="ss-tier-tier-list-s1-hint" aria-pressed="false" aria-roledescription="draggable tile">
<span class="name">Tetris</span>
</button>
</li>
</ul>
</div>
<div class="row" data-tier="B" style="--ss-tier-color: var(--ss-yellow);">
<div class="letter" id="ss-tier-tier-list-s1-B">B</div>
<ul class="zone" data-zone="B" aria-label="B"></ul>
</div>
<div class="row" data-tier="C" style="--ss-tier-color: var(--ss-fg-muted);">
<div class="letter" id="ss-tier-tier-list-s1-C">C</div>
<ul class="zone" data-zone="C" aria-label="C"></ul>
</div>
<div class="row" data-tier="D" style="--ss-tier-color: var(--ss-red);">
<div class="letter" id="ss-tier-tier-list-s1-D">D</div>
<ul class="zone" data-zone="D" aria-label="D"></ul>
</div>
<div class="tray-row">
<span class="tray-label">Unranked</span>
<ul class="zone tray" data-zone="__tray__" data-tray="" aria-label="Unranked">
<li class="cell" data-item="sonic">
<button type="button" class="tile" data-item="sonic" aria-label="Sonic" aria-describedby="ss-tier-tier-list-s1-hint" aria-pressed="false" aria-roledescription="draggable tile">
<span class="name">Sonic</span>
</button>
</li>
<li class="cell" data-item="doom">
<button type="button" class="tile" data-item="doom" aria-label="Doom" aria-describedby="ss-tier-tier-list-s1-hint" aria-pressed="false" aria-roledescription="draggable tile">
<span class="name">Doom</span>
</button>
</li>
</ul>
</div>
</div>vanilla.js adds pointer drag between and within rows (Pointer Events, so touch works) and the keyboard path — Space picks a tile up, arrows move it, Space drops, Escape cancels — announced through the `.live` region. Emits `ss:change` on the root with `{ placements }`; `data-ss-readonly` on the root makes it display-only.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tiers | TierListTier[] | S / A / B / C / D | The rows, top to bottom: `{ id, label, color? }`. `color` is any CSS colour (prefer a token, `var(--ss-blue)`); by default rows take palette slots by index — accent, cyan, yellow, muted, red, blue, magenta, green — cycling for longer lists. |
items | TierListItem[] | — | Every rankable item: `{ id, label }`. `label` is the tile's accessible name and the default tile text; items in no tier sit in the tray. |
placements | Record<string, string[]> | — | Tier id → ordered item ids (bindable). The tray is never part of it. Omit it to let the component keep its own order. |
onchange | (placements: Record<string, string[]>) => void | — | Fired after every completed move (pointer drop or keyboard drop) with the new placements. |
onselect | (item: TierListItem) => void | — | Activation — Enter, or a click that is not a drag (open a details view, say). When omitted, Enter picks the tile up like Space. |
tile | Snippet<[TierListItem]> | — | Tile content; defaults to the label. Images/SVG fill the tile frame edge to edge. |
tray | string | false | 'Unranked' | Label of the unranked tray, or `false` to hide it (then every item must appear in `placements`). |
label | string | 'Tier list' | Accessible name of the whole list (`role="group"`). |
readonly | boolean | false | Display only: tiles are plain boxes, nothing drags or sorts. Renders `data-ss-readonly` on the root (the plain-HTML switch). |
size | 'sm' | 'md' | 'lg' | — | Per-instance size override; inherits the ancestor `data-size-variant` when unset. |