GitHub repository

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

live · storybook Open in Storybook

Demo loads from https://dssoca-storybook.vercel.app/. Run pnpm storybook for it to render locally.

Usage

svelte
<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.

Guide: Plain HTML & CSS — the vanilla.js behaviours.

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.

html
<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

PropTypeDefaultDescription
tiersTierListTier[]S / A / B / C / DThe 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.
itemsTierListItem[]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.
placementsRecord<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[]>) => voidFired after every completed move (pointer drop or keyboard drop) with the new placements.
onselect(item: TierListItem) => voidActivation — Enter, or a click that is not a drag (open a details view, say). When omitted, Enter picks the tile up like Space.
tileSnippet<[TierListItem]>Tile content; defaults to the label. Images/SVG fill the tile frame edge to edge.
traystring | false'Unranked'Label of the unranked tray, or `false` to hide it (then every item must appear in `placements`).
labelstring'Tier list'Accessible name of the whole list (`role="group"`).
readonlybooleanfalseDisplay 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.
↑↓ navigate · ↵ open · esc close

Keyboard shortcuts

No shortcuts registered

Shortcuts added through the dssoca registry will show up here.

Single-key shortcuts