Entity Row
Icon, title, description and trailing slot in a fixed-height row.
What it is
One line in a list of things: an optional icon, a title, an optional description, and a trailing slot that takes a badge, a chevron, a switch or a price without changing the row's shape. It is the smallest unit of every menu, connector list, provider list and queue in this registry — seventeen components compose it rather than drawing their own row.
Why it matters
After the card, this is the most repeated shape on the reference board: Manus skills, Freepik's AI tools, Zapier starters, Tripo's settings panes and Lovable connectors are all the same four slots at the same height. Centralising it is what stops a product from growing four subtly different list rows — and, more practically, it is where one accessibility decision gets made once for everyone. A row that paints itself rebinds `--muted-foreground` for its whole subtree, so a description or a price you pass into the trailing slot stays readable on the selected surface instead of landing at 4.34:1.
Observed in: Manus · Freepik · Zapier · Tripo · Lovable
Anatomy
- 1
entity-rowThe row itself. A <button> with aria-pressed when you passonSelect, a plain <div> otherwise — the same markup either way, one interactive and one not. - 2
entity-row-iconLeading glyph, rendered only wheniconis set. Decorative by convention: mark your own icon aria-hidden, the slot does not do it for you. - 3
entity-row-titleRequired, truncates. This is the row's accessible name. - 4
entity-row-descriptionOptional second line, truncates. Omitting it does not shorten the row — height is reserved either way. - 5
entity-row-trailingWhatever sits at the end: a badge, a chevron, a switch, a cost chip. Never shrinks, so long titles give way first.
How to use it
Reach for it for any list where each line names a thing — a skill, a model, a connector, a sign-in provider, a queued job. Pass `onSelect` when picking the row is the action, and leave it off when the row is a container for a control that lives in `trailing`; those are the two shapes, and a row should never be both. `selected` is yours to hold: the row renders what you give it and never toggles itself, so a list keeps its selection in one place rather than in every row. Description is optional per row — mixing rows with and without one is expected and will not make the list look ragged.
Do
Give the row exactly one control — either the row is clickable, or something in its trailing slot is.
Point an interactive trailing control at the row title for its name, by putting an id on the element you pass as
title.SlackPost run summaries to a channel
Don't
Don't pass onSelect and an interactive trailing control at the same time — that nests a button inside a button, doubles the tab stops and fails axe.
SummarizeCondense a long documentDon't drop a bare switch or button into the trailing slot and assume the row title names it — nothing connects them.
SlackPost run summaries to a channelDon't put a chevron on a row that toggles or picks rather than navigates — the arrow is a promise of a next screen.
Accessibility
- Keyboard
- With `onSelect` the row is one tab stop and a real `<button>`: Space and Enter both fire it. Without `onSelect` it is a `<div>` with no tab stop at all, and the only reachable thing is whatever you put in `trailing`.
- A list of rows is one tab stop per row. There is no roving tabindex and no arrow-key navigation, so twenty rows is twenty Tab presses and Up and Down do nothing.
- `disabled` behaves differently in the two shapes and only one of them is safe. An interactive row becomes `<button disabled>` and leaves the tab order. A non-interactive row gets `aria-disabled` and `pointer-events-none`, which stops the mouse and not the keyboard — a switch or button in `trailing` stays tabbable and stays activatable by Space on a row that looks disabled.
- No Escape, no Delete, no shortcut of any kind. Acting on a row from the keyboard means a control in `trailing`, and that control is a second tab stop.
- Screen reader
- An interactive row announces as a toggle button, because it always renders `aria-pressed` — `true` or `false`, never absent. A row that navigates therefore reports a pressed state that nothing in the product toggles.
- The row's accessible name is computed from its contents, so title, description and any text in `trailing` are read as one string: a row titled "Anthropic" with the description "Checked 2 minutes ago" and a "Key invalid" badge announces as all three.
- The icon slot is not `aria-hidden` — it only carries a colour class. A lucide glyph hides itself, but any other node you pass adds its text to that name.
- A non-interactive row has no role. It is a bare `div`: not a list item, not a group, not announced as a unit. If the list matters, wrap the rows in your own `ul`/`li`.
- `aria-disabled` on a non-interactive row marks the row and nothing inside it, so descendant controls stay enabled to assistive tech. That is the same hole the keyboard note describes, seen from the other side.
- Nothing announces a selection change. `selected` flips `aria-pressed` and `data-state` with no live region, so a list whose selection moves from anywhere other than the row itself changes silently.
- Focus
- Only the interactive shape ships a `focus-visible` ring. A non-interactive row's `trailing` control carries whatever focus style you gave it, and the row itself can never take focus.
- Nothing moves focus, and the row is a `<button>` in one branch and a `<div>` in the other — so toggling `onSelect` on a focused row swaps the element out from under the focus and drops it to `<body>`.
Watch out for
- Any row with
onSelectrenders aria-pressed, including one whose trailing chevron says it navigates. A screen reader will announce a pressed state that nothing in the product actually toggles. Until that is fixed, prefer a plain row plus your own link for navigation rows, or accept that the row reads as a toggle. - The component spreads
...propsafter its own attributes, so passingdata-slotoverwritesdata-slot="entity-row"and makes the row invisible to every selector that looks for it. Address rows by a data attribute of your own (data-action-idis the pattern action-stack and ai-tools-menu use) rather than renaming the slot. disabledproduces two different renderings: an interactive row becomes a real<button disabled>, a non-interactive one getsaria-disabled. Both dim to 50% opacity, which is a visual signal only — if the reason a row is unavailable matters, say it in the description rather than relying on the dimming.- The row aligns its text with
text-start, so the title and description followdiralong with the flex order. Anything you pass intotrailingis your own markup and does not: physical classes there (ml-*,text-left, aChevronRightglyph) will not mirror, so reach for the logical form or flip the icon at the call site.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/entity-row.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.