Track List
Music library rows
| Artwork | Track | Tags | Preview | BPM | Key |
|---|---|---|---|---|---|
Not set | Nova Kane | synthwaveinstrumental | 124 | F minor | |
Not set | Ilya Sound | ambientloop | 92 | C major | |
Not set | Nova Kane | Not set | 124 | Not set | |
Not set | Not set | Not set | Not set |
What it is
A table of music library rows: cover art, title and artist, tags, a row-height waveform you can audition in place, tempo and musical key. Every row is a track; every column is something you would compare across tracks.
Why it matters
Tempo and key are the two fields a music library is actually sorted and filtered by, and a generic asset list cannot express either — which is why this exists as its own component instead of being folded into `asset-library`. Once those fields are columns rather than free text, two takes line up digit for digit and the comparison costs nothing. That is also why this is a table while the rest of the library family is grid-shaped: a card grid looks better and hides the numbers you opened the library to compare. The inline waveform follows the same logic — it is an audition affordance, not artwork. Playing a row must leave you exactly where you were, because comparing three takes should not cost three round trips through a detail view. And because a separated stem has no cover art and a spoken clip has no key, absence is a first-class value here: a missing field is stated, never filled in with a zero that reads as a measurement.
Anatomy
- 1
track-listThe table wrapper. - 2
track-list-rowOne track. Carriesdata-state—playingoridle. - 3
track-list-artworkCover art cell. Your own decorative node, or a stated absence. - 4
track-list-titleThe track title. A button whenonSelectis given, otherwise text. - 5
track-list-artistSecondary line under the title. - 6
track-list-tagsFree-text tags. Not where tempo or key belong. - 7
track-list-playThe audition control, named for its track. Rendered only whenonPlayToggleis supplied. - 8
track-list-waveformRow-height preview strip, decorative.data-peaks="none"when the track has not been analysed. - 9
track-list-waveform-barOne bar per supplied peak; bars behind the playhead fill while the row is sounding. - 10
track-list-bpmTempo. Right-aligned and tabular so columns compare at a glance. - 11
track-list-keyMusical key, as free text — notation conventions differ. - 12
track-list-unsetA deliberate absence: an em-dash hidden from assistive tech, paired with the words "Not set".
How to use it
Reach for this over a generic asset library the moment tempo or key matter — those two fields are the whole reason it is a separate component, and a list that cannot sort by them is an asset library with music in it. Keep it a table: the columns exist to be compared, and turning it into a card grid throws that away. Playback is yours: the component holds no audio element and no timer, so hand it `playingId` and a `progress` percentage from whatever is actually sounding and handle `onPlayToggle` by starting or stopping in place. Sorting is yours too — pass `tracks` in the order you want them, driven by whichever column the user picked. Give `onSelect` only if opening a track is a real destination; it renders as a control on the title, deliberately separate from play.
Do
Give tempo and key their own columns, so two takes can be compared without opening either.
Artwork Track Tags Preview BPM Key Not setMidnight DriveNova Kanesynthwave 124 F minor Not setPaper LanternsIlya Soundambient 92 C major Let a row with no cover art, no tags or no key say so — absence is a value.
Artwork Track Tags Preview BPM Key Not setMidnight DriveNova Kanesynthwave 124 F minor Not setDrums (stem)Nova KaneNot set 124 Not set Not setVoice memo 04Not set Not set Not set
Don't
Don't bury tempo and key in free-text tags; nothing there can be sorted, filtered or lined up.
Artwork Track Tags Preview BPM Key Not setMidnight DriveNova Kanesynthwave124 BPMF minor Not set Not set Not setPaper LanternsIlya Soundambient92bpmkey of C Not set Not set Don't fill an absent field with a zero or an "n/a" — a stem is not 0 BPM, and the filler reads as a measurement.
Artwork Track Tags Preview BPM Key Not setDrums (stem)Nova Kaneuntagged 124 n/a Not setVoice memo 04untagged 0 n/a
Accessibility
- Keyboard
- A row's tab stops are decided entirely by which handlers you pass: none with neither, one with `onPlayToggle`, two with both. A hundred tracks with both wired is two hundred stops, in table order, with no way to skip a row.
- Play/pause is a real `<button>`, so Enter and Space toggle it and it calls `onPlayToggle(id, !playing)`. Without `onSelect` the title is a plain `<span>`, so there is no keyboard route into a track at all.
- The component installs no key handlers. There is no Space-plays-the-focused-row, no arrow-key movement between rows, no Escape to stop playback and no type-ahead.
- The vendored table wraps itself in an `overflow-x-auto` container with no `tabIndex`, and every focusable control sits in the first four columns. At a narrow viewport BPM and Key scroll off to the right and no keyboard can bring them back — the two columns the component exists for become mouse-only.
- Nothing is ever `disabled`. A track with no peaks and no audio still renders an enabled Play button whenever `onPlayToggle` is supplied.
- Screen reader
- It is a real `<table>` with `<th scope="col">` on every column, so each cell announces with its header. The artwork column's header is a visually hidden "Artwork" rather than an empty cell, and `label` lands as `aria-label` on the table itself.
- The play control is named for its track — "Play Midnight Drive" / "Pause Midnight Drive" — so the announced name is the state, and the icon changes shape as well, which keeps it off colour.
- An absent value renders through `NotSet`: the em-dash is `aria-hidden` and a visually hidden "Not set" is carried beside it, so an unknown BPM, key, tag set or cover announces the words rather than a dash, a blank or a zero.
- The inline waveform is entirely `aria-hidden`, played portion included. `progress` moves the bars and nothing else, so how far through a track playback has reached is conveyed to nobody — as is the row's `data-state="playing"` tint.
- There is no live region. Playback starting or stopping announces only through the Play button's own name changing, and only if that button still has focus; starting playback from anywhere else on the page is silent.
- Tags render as `Badge` spans with no role, so they read as a run of plain text inside the Tags cell rather than as a list with a count.
- The title control and the play control are siblings, never nested, so a row is two independent controls rather than one interactive inside another.
- Focus
- Nothing here mounts, unmounts or moves focus. A row does not change shape when it starts playing — the same controls stay in the same cells — so focus survives playback, and re-sorting `tracks` keeps focus with the same row's control as it moves, because rows are keyed by `track.id`.
- Both controls take the vendored `Button`'s `focus-visible` ring. The artwork `PreviewTile` is rendered without `onSelect`, so it is an inert `<div>` and never a focus stop.
- The only way to strand focus is your own data: dropping a track out of `tracks` while its Play button has focus leaves focus on `<body>`.
Watch out for
- Auditioning must not navigate. If your
onPlayTogglehandler routes to a detail page, you have re-created the round trip this component exists to remove — start playback in place and leave the list mounted. - The play control renders only when
onPlayToggleis supplied. A list that silently has no way to audition is usually a forgotten handler, not a decision. progressapplies toplayingIdalone. Setting it while nothing is playing does nothing, which normally means the playing id was not updated.- The waveform is decorative and hidden from assistive tech; it is not a scrubber and reports no position. If you need seeking, that belongs on a transport control, not on a 24px strip.
bpmis checked for being a number, not for truthiness — passundefinedfor an unknown tempo, because0is a value and will render as one.- The title control and the play control are siblings, never nested. If you wrap the row in a link or a button to make it clickable, you have created a nested interactive and the accessibility gate will fail.
- This is not H6
waveform-editor. If you want selection, zoom or regions, you want the editor; this strip is a preview you scan at row height.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/track-list.jsonRequires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.