Typed, accessible, theme-aware React/Tailwind components installable by the shadcn CLI or your AI coding agent. Free atoms plus composed Pro blocks.
A structured editor for a repeat: frequency and interval, the weekdays or the day-of-month beneath them, and a three-way ending — answering with an RFC 5545 RRULE string, with the rule written back out as a sentence and the next dates listed beside it so it can be checked. Reach for it wherever a form asks *how often* rather than *when*: a recurring appointment, class or booking; a reminder or task that repeats; recurring invoices, subscriptions and billing cycles; a scheduled report, digest or export; an on-call rota or shift pattern; a publishing or content calendar; medication and habit reminders; maintenance windows and payroll runs; a retention or backup policy stated for people rather than for cron; and the “Custom…” dialog behind every “Repeats: weekly” dropdown. Common asks it answers: “recurrence input react”, “rrule editor react”, “RRULE builder component”, “recurring event form react”, “repeat options component react”, “shadcn recurrence picker”, “shadcn repeat schedule input”, “react-rrule-generator alternative”, “rrule.js UI component”, “build a recurrence rule UI”, “custom recurrence dialog like Google Calendar”, “outlook recurrence dialog react”, “iCalendar RRULE form”, “FREQ BYDAY BYMONTHDAY editor”, “repeat weekly on selected days react”, “every second Tuesday of the month”, “last Friday of the month picker”, “monthly on the last day of the month”, “end after N occurrences”, “recurrence until date”, “human readable recurrence rule”, “describe an rrule in words”, “preview next occurrence dates”, “rrule skips february”, “monthly on the 31st skips months”, “why does my monthly rule miss a month”, “recurrence count does not match the end date”, “COUNT and UNTIL together rrule”, “BYDAY ordinal not allowed weekly”, “dtstart not synchronized with recurrence rule”, “rrule until must be UTC”, 繰り返し設定 UI, 定期予定の入力フォーム, 「毎月第2月曜」の選択, RRULE エディタ, 毎週 曜日 複数選択, 月末 繰り返し, 繰り返しルール コンポーネント, 終了条件 回数 期日. Official shadcn/ui has nothing of the kind, and the measurement says so precisely: across its whole registry today — 65 items in the catalogue, 62 fetchable (code-block, questionnaire and trace 404 on both style tracks), 223,287 bytes of files[].content concatenated — RRULE, recurrence, FREQ, BYDAY, BYMONTHDAY, BYSETPOS, recurring and interval are every one of them a zero hit. Its calendar is a react-day-picker wrapper that chooses one day and has no notion of repetition at all. Distinct from the neighbours, and the pair worth knowing is cron-expression: that one reads a machine's cron string and renders it, where this one is the editor a person drives, and the two vocabularies genuinely differ — cron has no COUNT, no UNTIL and no “last weekday of the month”. weekly-hours sets opening times across a fixed seven rows rather than a rule that grows; date-range-preset picks a one-off period to look at; repeatable-fields repeats a row of form fields; duration-input measures a length of time rather than a cadence. Five things go wrong in the hand-rolled version, and the first is the one that costs money. “Monthly on the 31st” is not monthly. RFC 5545 does not clamp: it says a rule may generate an instance with an invalid date such as February 30, and that such instances “MUST be ignored and MUST NOT be counted as part of the recurrence set”. So BYMONTHDAY=31 fires in seven months of the year, and because a skipped instance is not counted, pairing it with COUNT=12 does not end in a year — starting in January 2026 the twelfth firing is 31 August 2027. Nothing errors; the schedule a person reads back as “monthly for a year” is neither monthly nor a year. Several products clamp instead, and that is a different schedule which cannot be written as BYMONTHDAY=31 at all — it is BYMONTHDAY=-1, the last day of the month, which exists in every month. This component makes that a separate, named choice and shows how many months the other one skips, rather than guessing, because either guess is invisible. (RFC 7529's SKIP=BACKWARD, the standardised way to ask for clamping, is recognised on parse so a rule carrying it is not mistaken for a plain one and stripped of the part that made it clamp.) Second, changing the frequency does not just swap the panel: “on the 2nd Tuesday” cannot mean anything once a rule repeats weekly — RFC 5545 forbids a numbered weekday outside MONTHLY and YEARLY — so it is dropped, and frequencyChangeDrops exists so the form can say what went instead of silently keeping or silently losing it. Third, the ending is one choice of three, not a times box and a date box: the spec says UNTIL and COUNT “MUST NOT occur in the same 'recur'”, and a form with both live eventually emits both. Fourth, a rule you cannot read back is a rule you cannot check — “the 2nd” and “the second Monday” are different schedules that look identical in a dropdown — so the sentence and the next dates are part of the control, and the dates are computed without reading the clock, so the same props always render the same markup. Fifth, the zone: this component edits a calendar day and does not own DTSTART, and RFC 5545 requires UNTIL to match DTSTART's value type — UTC when DTSTART carries a TZID — so untilForm is an explicit option rather than a guess, and a start date the rule does not actually generate (RFC 5545 calls that combination undefined) is reported rather than papered over. The date arithmetic is cross-checked against rrule.js across 26 rules covering every BY* part this editor can produce, because matching the implementation the rest of the ecosystem is built on matters more than being defensibly different — a preview is only worth having if its dates are the ones the caller's calendar will later produce. That comparison is what settled the DTSTART question; it added no dependency here. What you get: RecurrenceInput, controlled with value + onValueChange or uncontrolled with defaultValue, taking dtstart for the preview and the field seeding, previewCount, a per-part classNames, disabled on a real fieldset so your own fields go with it, full labels for translation, and name to post the RRULE with a plain form or a server action. The hard decisions are exported as pure functions and can be asserted on their own: parseRRule (which refuses a sub-daily frequency and lists the parts it cannot edit instead of rewriting them), formatRRule, describeRecurrence, nextOccurrences (reporting how many instances were skipped and whether DTSTART is one of them), monthDaySkip, changeFrequency, frequencyChangeDrops, setMonthlyMode, toggleWeekday, setEnding and recurrenceIssues. Keyboard and screen reader throughout: a real fieldset and legend, radiogroups for the monthly shape and the ending, aria-pressed weekday toggles whose last remaining day is aria-disabled with the reason wired up rather than silently inert, and a permanently mounted polite live region carrying the sentence so a change is announced rather than only redrawn. Styled entirely with shadcn tokens (primary, input, accent, ring, muted, muted-foreground, destructive, border) so it follows light and dark mode, and it ships zero npm dependencies — no date library, no rrule library, no icon package.
A set of identical field rows with an "add another" button under them and a remove button on each: the caller renders one row's fields, this owns the list, the identity of each row, and where focus goes when the list changes shape. Reach for it wherever a form takes a list rather than a value: work history and education on a CV or profile, the line items of an invoice, quote or expense claim, environment variables and their values in a deploy or settings screen, several emails, phone numbers or addresses on one contact, the headers, query parameters or body fields of an API request builder, a product's variants and their prices, the answer options of a survey or poll being written, team members being invited by email, redirect URIs and allowed origins on an OAuth app, webhook endpoints, ingredients and steps in a recipe, authors on a paper, guests on a booking, and the "add another" row of any admin screen that edits a one-to-many relation. Common asks it answers: "repeatable fields react", "repeating form fields", "dynamic form fields react", "add more fields button react", "add another row form", "add remove input rows react", "field array react", "useFieldArray without react-hook-form", "useFieldArray alternative", "react-hook-form field array shadcn", "shadcn repeatable fields", "shadcn dynamic form rows", "shadcn add another field", "shadcn field array", "shadcn invoice line items", "multiple email inputs react", "add multiple phone numbers form", "key value pair input react", "environment variables input react", "http headers input react", "invoice line items component react", "work experience form repeatable", "education fields add more", "product variants form react", "survey options editor react", "add remove list of inputs", "duplicate form row react", "variable length form react", "array of objects form react", "nested form array react", "deleting a row shows the wrong value", "removing an item keeps the value of the next one", "react list delete wrong item", "input value jumps to another row after delete", "wrong row deleted react", "deleted row value appears in the row below", "why not to use index as key", "react key index bug form inputs", "stable keys for dynamic form rows", "uncontrolled input loses value on remove", "focus new row after adding react", "autofocus newly added input react", "focus first field of new row", "focus lost after removing a row", "focus jumps to body after delete react", "tab starts from top of page after removing a row", "where should focus go after deleting an item", "manage focus when removing an element react", "screen reader does not announce added row", "announce row added aria-live", "remove buttons all say remove", "accessible name for delete row button", "label each remove button by its row", "aria-label for repeated delete buttons", "fieldset legend repeated fields", "group repeated form fields accessibility", "minimum one row required form", "limit number of rows form react", "disable add button at max rows", "keep at least one field react", "可変長フォーム react", "入力欄を動的に追加", "もう1つ追加 ボタン", "入力行の追加と削除", "明細行の追加 フォーム", "環境変数の入力欄", "配列のフォーム react", "index を key にすると壊れる", "行を削除したら下の行の値がずれる", "追加した行にフォーカスを移す", "削除後のフォーカス移動", "削除ボタンの読み上げ名". Official shadcn/ui has the shell and none of the behaviour, and the measurement says so precisely. Across its whole registry today — 63 items in the catalogue, 62 fetchable, `questionnaire` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `useFieldArray`, `fieldArray`, `repeatable`, `Add another`, `appendField`, `removeField` and `duplicateRow` are every one of them a zero hit. What official does ship is `field`, and it is worth being exact about what that gives you: `FieldSet` is a styled `<fieldset>`, `FieldLegend` a styled `<legend>`, `FieldGroup` a flex column, and it owns every one of the 2 `fieldset` and 7 `legend` occurrences in the registry. In its 6,170 bytes the words `add` and `remove` do not appear once. So the grouping is solved and the repetition is not. `form` is the other near-miss: 3,758 bytes wiring one field to its label, description and message — `FormField` occurs 17 times in it — and a field array is not in its vocabulary at all. This component is deliberately not built on either of them: the shell is nine lines of flexbox, and a project that has installed `field` can hand this component's `className` the same classes and the two sit together. Five things go wrong in the thirty-line version, and every one of them is invisible to a mouse. The first is the famous one, and the usual summary of it — "don't use the index as a key" — names the fix rather than the failure. React uses the key to decide which previously rendered row a row corresponds to. Keyed by position, deleting the second of three rows means the row that was third is now second, so React matches it to the second row's DOM nodes and keeps them — and an `<input>`'s text is DOM state, not something React re-renders. The value typed into row 3 is then sitting in row 2's box: nothing throws, the row count is right, every box has text in it, and what the person sees is the row they deleted having eaten the row below it. Uncontrolled inputs lose the value outright; controlled ones recover on the next render, but the caret, the selection, the scroll position and anything a ref is holding do not. So identity has to come from somewhere other than the position, and this mints a key per row and keeps it — rather than requiring an `id` on each row, which would be wrong here because these rows are form values that get submitted, and a synthetic id the caller adds on the way in and strips on the way out is the kind of chore that gets skipped. Keys survive the caller replacing the array too, matched by object identity and consumed as they are claimed, because `rows.filter(...)`, a reorder and an append all preserve the identity of the rows they keep. Second, focus after adding: left on the add button, the new row's first field is one Tab per field in every row above it, so a long form punishes the keyboard for every row it gains. Focus moves into the new row's first field here, which is also why the remove button is rendered after the row's fields — `querySelector` answers in document order, and the selector matches a button too, so putting it first would open every fresh row with focus on the control that deletes it. Third, focus after removing: the button just pressed no longer exists, and a browser does not move focus when the node holding it is removed — it drops focus to `<body>`, so the next Tab starts again at the top of the page and a screen reader says nothing. The rule here is the row that took the vacated slot, else the row now last, else the add button, which means walking a list of six down to one is six presses in one place rather than six presses and five hunts for the button. Fourth, the remove buttons: n of them called "Remove" tell a screen-reader user how many rows there are and nothing about which button takes which, so each is named for its row — "Remove variable 2 of 3" — and each row is a `role="group"` named the same way, so entering one says where you are. Fifth, the floor and the ceiling: `min` and `max` are `aria-disabled` rather than the `disabled` attribute, so the control keeps its tab stop and announces itself as unavailable instead of silently vanishing or sitting there doing nothing, and the reason is on screen and wired up with `aria-describedby`. Adding and removing are also announced through a polite live region, because neither changes any field's value — without it the only evidence a row appeared is that focus moved, which says where you are and not what happened. What you get: `RepeatableFields`, generic over your row type, controlled with `value` + `onChange` (handed the whole next array and a `{ type, index }` saying what changed) or uncontrolled with `defaultValue`; `newRow`, the factory for a blank row; `children` as a render function receiving `{ row, index, total, rowId, update, remove, canRemove }`, where `rowId` is the stable minted key handed over so a label in row 2 can point at row 2's input; `legend` and `description` in a real `<fieldset>`/`<legend>`; `emptyMessage` for a set with nothing in it; `min` and `max`; `noun`, one prop that rewrites every accessible name at once ("Add another recipient", "Remove recipient 2 of 3"); `disabled`, which goes on the fieldset so the browser disables the caller's fields too without a prop being threaded down; `labels` for translating any of it; and per-part `className` for the set, a row, and each button. `nextFocusAfterRemove` and `alignRowKeys` are exported as pure functions, so the two decisions that are actually hard — where focus lands and which row keeps which key — can be asserted on their own. One file, two lucide icons, no `react-hook-form` and no form library; the array drops straight into `react-hook-form`'s `Controller` or any controlled state, and the fields are yours, so their `name` attributes and validation stay where they were. Distinct from the neighbours, and the pair worth knowing is `tag-input`: that one holds a `string[]` of short free-text values as chips inside a single field, this one repeats a whole row of several fields. `multi-select` picks from a fixed set of options you supply, and `nested-checkbox` selects from a hierarchy — both choose among things that already exist, where this one makes new ones. `sortable-list` reorders rows that already carry their own `id` and does not add or remove them. `upload-list` and `filter-chips` show a collection the person did not type. `form-error-summary` collects what went wrong after a submit, and `weekly-hours` is a fixed seven rows rather than a list that grows.
The small inline "Saving… / Saved 2 minutes ago / Couldn't save · Retry" indicator that sits beside an autosaving surface, and the announcement that goes with it. Use it wherever edits persist in the background instead of behind a Save button: a document, note or rich-text editor (Tiptap, ProseMirror, Lexical, Slate, Quill), a code editor pane (Monaco, CodeMirror), a settings, profile or account page that saves on blur, a draft post, email or message composer, a form with debounced autosave, a spreadsheet-style inline-edit table, a kanban card or CRM record, a form builder, page builder or design canvas, a collaborative document backed by a CRDT (Yjs, Automerge, Liveblocks), and anything wired to a background mutation — a TanStack Query mutation's isPending and isError, a Next.js server action behind useActionState or useFormStatus, react-hook-form with autosave, tRPC, SWR, Convex, Firebase or Supabase writes. Common asks it answers: "autosave indicator", "saving spinner next to the title", "all changes saved", "changes saved automatically", "draft saved status", "last saved timestamp", "saving saved error state", "Google-Docs-style save state", "Notion-style saving indicator", "how to show saving/saved/error", "autosave status react", "save state component", "shadcn autosave indicator", "shadcn save status". Official shadcn/ui ships nothing for this, and the parts that look close are all a different shape: sonner and toast pop a transient message after an action and then leave, so nothing on screen still says the document is saved a minute later; spinner covers a single in-flight request and has no resting, saved or failed state; badge renders a label but carries no state machine, no relative time and no live region; and field and message report validation on a control rather than the fate of a background write. You pass one `status` prop (idle | saving | saved | error). idle renders nothing visible, so it can be rendered unconditionally and simply mirror your mutation state rather than being mounted and unmounted around it. Pass `savedAt` and "Saved" is followed by a live relative timestamp that keeps itself fresh — it composes the time-ago component instead of freezing a string that goes stale while the tab sits open, which is the first thing a hand-rolled one gets wrong. Pass `onRetry` and the error state grows a Retry button. Every label (savingLabel, savedLabel, errorLabel, retryLabel) is overridable for i18n. Accessibility is the fiddly part, and it is the reason to take this rather than write it. The wording lives in an always-mounted role="status" region, so the very first transition is actually announced — a region that mounts at the same moment as its text is registered too late and its first "Saving…" is dropped silently, which is exactly what conditional rendering produces. The ticking timestamp and the Retry label sit outside that region on purpose: inside it, the timer would make the page announce "Saved 3 minutes ago" every minute, unprompted, for as long as the tab is open. And it stays polite rather than assertive even for failures, because aria-live is honoured at registration time and a failed autosave should not cut across someone mid-sentence. Distinct from toast, which is a transient notification after an explicit action, and from spinner and loading-button, which cover one request in flight: this is the persistent, resting status of a background save. Styled with shadcn tokens (muted-foreground, destructive, ring) so it follows light and dark themes; it composes time-ago and uses lucide-react for its icons.
A bar that fills as the reader scrolls — the reading indicator across the top of an article, and the "how much is left?" cue on anything long. Use it on blog posts and long-form articles, documentation pages, guides and tutorials, changelogs and release notes, terms / privacy / policy pages, onboarding and multi-section landing pages, reports, and long forms or checkout flows where the reader wants to know how much further there is to go. Common asks it answers: "reading progress bar", "scroll progress bar react", "scroll indicator component", "article reading progress", "page scroll percentage", "Medium-style progress bar", "progress bar at top of page on scroll", "how far down the page has the user scrolled", "scroll-linked progress indicator", "blog reading indicator", "useScrollProgress hook", "track scroll position in React". Drop in `<ScrollProgress className="fixed inset-x-0 top-0 z-50" />` for the classic placement, or render it as an ordinary block under a sticky header. Pass `target={articleRef}` when progress should mean "through this article" rather than "down this page" — on a page that continues into related posts, a comment thread or a tall footer, a whole-page bar is still short of the end when the article has actually been read, and a tracked element fills exactly as the last line arrives. `indicatorClassName` styles the filled part; the track and fill use your `--muted` and `--primary` tokens, so both themes follow automatically with no hardcoded colours. It settles the details a hand-rolled version gets wrong. Measurement is throttled to one requestAnimationFrame per scroll burst and quantised before it reaches state, so a flick that moves the bar by less than a fifth of a pixel re-renders nothing. Content that grows after first paint — an image finishing decoding, a lazily loaded section, an accordion opening, a web font swapping in — is picked up through a ResizeObserver and `document.fonts.ready`, where a scroll-and-resize-only implementation keeps reporting the old page height. A tracked element inside an app shell that scrolls its own `<main>` instead of the window is measured against that scroller, not the viewport, which is the layout where a naive bar sits frozen. When the content already fits on screen the bar reads full rather than empty, because everything there is to read is visible — the usual choice of 0 leaves a permanently empty bar on every short page, which looks broken rather than finished. The first paint is server-safe: it renders an empty bar on the server and takes its real measurement in a layout effect before the browser paints, so there is no hydration mismatch and no visible jump on a page restored mid-scroll. Decorative by design — the scrollbar already tells assistive technology where the reader is, and a `role="progressbar"` updating every frame of a scroll is announced as a stream of numbers over whatever is being read, so the element is `aria-hidden` instead of noisy. `useScrollProgress` is exported for indicators this component does not draw (a percentage in the header, a circular ring, chapter markers) so they share one number instead of a second implementation that disagrees at the edges. No dependencies beyond React. Official shadcn/ui has nothing scroll-aware: its progress is a Radix bar you drive with a value you already have, not one derived from the reader's position.
A scrolling box whose edges fade out while there is more content past them, and stop fading the moment there is not — the cue that tells a reader a wide table has columns off to the right, or that a panel continues below the fold. Reach for it wherever a box scrolls inside a page that does not: a wide data table or admin grid seen on a laptop or a phone, a horizontal tab strip, filter-chip row or category rail, a card carousel, a code block or build log that scrolls sideways, a long terms-and-conditions or changelog panel, the body of a modal or drawer, a sidebar nav taller than the viewport, a chat or comment pane, a dashboard table inside a card, and any responsive table that overflows on small screens. Common asks it answers: "scroll shadow react", "fade edges of scrollable div", "shadow when content overflows", "indicate more content horizontally", "horizontal scroll indicator for table", "responsive table overflow indicator mobile", "detect if an element is scrollable react", "check if a div has overflow javascript", "is content overflowing react hook", "scrollWidth vs clientWidth react", "useOverflow hook", "show a gradient only when scrollable", "fade out overflow tailwind", "mask-image scroll fade", "NextUI ScrollShadow alternative", "Mantine ScrollArea shadow alternative", "shadcn scroll area shadow", "shadcn horizontal scroll fade", "scroll hint react", "scrollable region focusable axe", "keyboard scroll a div", "スクロールできることを示す影". The thing being solved is that overflow: auto will not tell you whether it is overflowing. A scrollable box looks exactly like a box that ends there, and the browser offers no hook for the difference — there is no :overflowing selector, no event, nothing in CSS at all. So a cut-off table reads as a complete table, and people file bugs about missing columns that were on screen the whole time. The only way to know is to measure scrollWidth against clientWidth and keep re-measuring, which is why this is a component and not three utility classes. Official shadcn/ui has none of that machinery. Grepping all sixty-three of its registry entries (sixty-two fetchable; questionnaire is listed but 404s), scrollWidth, clientWidth, offsetWidth, scrollLeft, scrollTop, ResizeObserver and MutationObserver are every one of them a zero hit. The word overflow appears in twenty-two components and is a Tailwind class each time — something that clips or scrolls, never a measurement of whether anything is spilling. scroll-area itself is a forty-nine-line Radix wrapper that restyles the scrollbar: no overflow detection, no edge shading, and one tabIndex in the whole registry (in sidebar). The version everyone writes first listens for scroll alone, and that is precisely backwards. It means the fade is missing until you scroll, and the moment the fade earns its keep is the one before anybody has touched the box: a table that arrives from a fetch already too wide, a sidebar opening and squeezing the page, a filter that adds a column, a details row expanding, a panel inside a tab that was display:none when it mounted. It looks right in development, where you scroll the thing you just built, and it is wrong on arrival for everyone else. So the position is re-read from five other places besides the scroll event, each covering a way the answer changes without one: the box being resized (which also covers it becoming visible, since that is a resize from zero), a child changing its own size, nodes added or removed anywhere inside, an image or iframe finishing loading (caught in the capture phase, since load does not bubble), and a web font swapping in and re-flowing every line. All of them funnel through one requestAnimationFrame, so a burst of mutations costs a single measurement, and state changes only when one of six booleans does — scrolling a long table end to end re-renders twice, not once a frame. Two measurement bugs are fixed that survive most rewrites. The first is right-to-left: the CSSOM puts scrollLeft at 0 at the initial position of an RTL scroller and runs it negative going left, so a fresh Arabic or Hebrew table reports scrollLeft === 0 with half its columns hidden off to the left — and every implementation that reads 0 as "nothing behind us" paints the fade on the wrong side, on first paint, where nobody is looking for it. The writing direction is resolved once, and only when there is horizontal overflow to resolve it for. The second is sub-pixel: layout is fractional while scrollWidth and clientWidth are rounded integers, so a box scrolled fully to the end lands a few tenths of a pixel short and a `hidden > 0` test leaves the end fade painted over the last column forever, on exactly the screens the author does not own. A one-pixel threshold settles it, and it is a prop. The fade is a mask on the content, not a gradient laid over it. An overlay has to be painted in the page background colour to look like a fade, so it has to be told what that colour is — and it is then wrong inside a card, wrong on a striped table, wrong over an image, and wrong in dark mode the day someone adds one, because the gradient stop was hardcoded once and never re-checked. A mask makes the content itself fall away, which is correct on every background without being told about any of them. When a box fits, no mask is emitted at all rather than an all-opaque one, since masking costs a stacking context and a composited layer; and when both axes scroll the two gradients are combined with mask-composite: intersect, because the default is add and two layers each opaque down their own middle would union into a mask that fades nothing but the four corners. It also takes a tab stop only while it actually scrolls. A div with overflow: auto and no focusable content inside cannot be reached and therefore cannot be scrolled without a mouse — a plain table of text or a wide code block is simply unavailable to a keyboard, which is what axe reports as scrollable-region-focusable. The usual fix is a permanent tabIndex={0}, which buys that at the cost of a dead tab stop on every one of these boxes that happens to fit; since the overflow is already being measured, the stop can exist exactly when it is useful. Pass aria-label and the box is announced as a named region as well, so a screen reader user is told what they have landed in rather than an anonymous group. The focus ring is drawn on the wrapper, outside the mask, because a ring on the masked element would fade out along with the content at the very edges it is meant to trace. The API is orientation ("horizontal", "vertical" or the default "both", which fades whichever axis turns out to scroll), size for the fade length, threshold, focusable, viewportRef and viewportClassName for the scrolling element itself, onEdgesChange, and data-more-top / -right / -bottom / -left plus data-scrollable on the wrapper so you can hang an arrow button or a shadow of your own off CSS. The unused axis is set to hidden rather than left visible, because CSS promotes visible back to auto as soon as the other axis is not, and a horizontal scroller written as overflow-x-auto alone grows a vertical scrollbar the first time a cell wraps. The wrapper is a column flex box so that a max-height put on it actually reaches the scroller instead of spilling, and it shrinks to zero in both axes so the widest table cell cannot dictate the width of the page around it. useScrollEdges, scrollShadowMask and the pure readScrollEdges are exported for a scroller you lay out yourself. Within pulld it is the piece that goes around virtual-list, table-like content and code-block; it shares its measure-then-observe discipline with scroll-progress, which reads how far down a page a reader is rather than what is hidden past an edge, and it is distinct from infinite-scroll, which loads more rows when you reach the end where this only says that an end is not yet reached. One file, no dependencies at all, and no colour of its own — the fade is the absence of paint, so light and dark follow for free.
Search field with a leading magnifier and a trailing clear (✕) that appears as soon as there is text, empties the field, and puts focus back so typing can continue. Reach for it wherever typing narrows what is on screen: above a filterable list or a data table, in a sidebar or a settings page, over the options of a dropdown or combobox, in docs and help search, in an admin record lookup, in a file or media library, over a long list of teams, projects or integrations, in a picker inside a dialog, and anywhere a "/" shortcut is meant to land in a search box. Common asks it answers: "search input", "search bar", "search box", "filter input", "clearable input", "input with a clear button react", "search field with icon", "type to filter a list", "table search box", "searchbar component", "shadcn search input", "shadcn input with clear button", "MUI TextField with clear button equivalent", "antd Input.Search equivalent", "chakra InputRightElement clear equivalent", "clear button does not trigger onChange react", "setting input.value does not fire onChange", "React set input value programmatically and fire change", "hide the native search cancel button", "focus the search box with a slash shortcut", "debounce a search input nextjs". Official shadcn/ui has nothing for this: onClear, clearable, search-cancel, nativeInputValueSetter, HTMLInputElement.prototype, dispatchEvent, searchValue and type="search" are each zero hits across all 62 of its fetchable items (measured 2026-09-26) — its input is a bare styled <input>, and its input-group is a layout kit of six parts (InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea) pulling in button, input and textarea, handing you slots to hang your own icon and clear control in. You still write the clear button, the show-it-only-when-there-is-text rule, the refocus and the event plumbing. This is that already assembled, in one import. Distinct from the pulld pieces it sits beside: command-palette is a modal that takes over the screen and runs commands, this is a field that stays on the page; filter-chips shows the conditions already applied and takes them off one at a time, while this is the one condition a person is typing; multi-select and tag-input collect values into a field rather than narrowing a list outside it; mention-input searches inside prose after an @; speech-input is the same job by voice; and highlight-text is what marks the matches in the results this produces. Pairs with all of them — a query in the URL that this field writes is a chip filter-chips can take off again. The part that is easy to get wrong is clearing. Assigning to the input's value does not make React's onChange fire, so a hand-rolled clear button empties the box while the list behind it stays filtered on the old query — the field looks reset and the results do not agree. This writes through the native value setter and dispatches a bubbling input event, so onChange fires for controlled and uncontrolled use alike and whatever filtering it drives actually updates. It also hides the WebKit search-cancel button so there is not a second ✕ beside the first, keeps the magnifier out of the accessibility tree and out of pointer events, gives the clear control a screen-reader label, and passes only one of value/defaultValue through so React never warns about a field switching between controlled and uncontrolled. The clear button is deliberately left out of the tab order, so Tab moves on to the next field rather than into a control that duplicates select-all-and-delete. Standard input props and a forwarded ref pass straight through, so a "/" hotkey can focus it. Theme-aware via shadcn tokens; depends only on lucide-react.
A row of 2–4 mutually exclusive choices drawn as one moving pill on a shared track — the iOS-style segmented control, and what most dashboards use to switch a view or a range without navigating anywhere. Reach for it wherever a single setting has a handful of choices that all fit on screen at once: List/Grid/Board on a file browser or project view, Day/Week/Month or 24h/7d/30d above a chart, Light/Dark/System in settings, °C/°F and km/mi on a unit preference, Monthly/Yearly on a pricing page, Newest/Oldest/Top on a feed, All/Active/Archived or Open/Closed on a list, Preview/Code on a docs example, Table/JSON/Raw on a response viewer, Chart/Table on a report, Personal/Team on a switcher, and the segmented filter at the top of a mobile screen. Common asks it answers: "segmented control", "segmented control react", "shadcn segmented control", "segmented button", "iOS segmented control react", "pill toggle", "pill switcher", "toggle switcher", "view switcher component", "list grid toggle react", "time range switcher", "date range toggle 7d 30d", "chart period selector react", "sort toggle component", "unit toggle celsius fahrenheit", "monthly yearly pricing toggle", "tabs without panels", "tabs but no content panels", "single choice button group react", "radio buttons as a button group", "radio group styled as buttons", "Ant Design Segmented equivalent", "MUI ToggleButtonGroup exclusive", "SwiftUI Picker segmented web", "shadcn tabs alternative no radix", "toggle group single selection", "sliding pill indicator react", "animated active indicator tabs", "accessible segmented control", "role radiogroup react", "aria-checked button group", "arrow keys select radio group", "roving tabindex radio", "keyboard accessible view switcher", "disabled option in a toggle group", "セグメンテッドコントロール", "切り替えボタン 3つ", "表示切り替え リスト グリッド", "期間切り替え 7日 30日", "タブだけどパネルは要らない", "ラジオボタンをボタン風に", "単一選択のボタン群", "矢印キーで選択 アクセシビリティ" — the control usually faked with a row of buttons and a useState. How it differs from the neighbours official shadcn/ui ships, measured across its whole registry today (63 items in the catalogue, 62 fetchable — questionnaire is indexed and 404s on both style tracks — and 223,287 bytes of files[].content concatenated): tabs (3,772 bytes) and toggle-group (2,309 bytes) both list radix-ui as a dependency, and button-group (2,203 bytes) depends only on cn but is a layout wrapper with no selection of its own. More to the point, the ARIA radio pattern is not in official's source to copy at all: radiogroup, role="radio", aria-checked and roving are every one of them a zero hit across all 223KB, because Radix owns that markup inside radio-group (1,450 bytes) rather than the wrapper shipping it. The word segmented does not appear once either. This is one file with no dependencies, and it is a real radio group — role=radiogroup on the track, role=radio and aria-checked on every segment — so assistive technology announces one setting with a selected option among several rather than a row of unrelated buttons. Tabs additionally owns panels and the tab/tabpanel relationship, which is the wrong contract when the choice only filters or reframes data already on the page, and a switch only covers two states. The keyboard follows the radio pattern rather than the button one: arrow keys (left/right and up/down) move and select in a single press and wrap around the ends, Home/End jump to the first and last usable segment, disabled segments are stepped over instead of trapping focus, and a roving tabindex keeps the whole group one tab stop with the selected segment as the entry point. Also: per-segment disabling as well as a whole-group disabled state, controlled or uncontrolled through a string value with onValueChange, a focus-visible ring, and shadcn tokens throughout so it follows the theme in light and dark.
A small toolbar that appears over text the moment it is selected, carrying your own actions: quote, copy, highlight, share, comment, define, translate, or "ask about this". Reach for it wherever the next thing a reader wants to do is about the words they just dragged across — a long-form article or documentation site, a proofreading or editorial review screen, an AI chat that quotes a passage back as the question, a knowledge base with highlights, a transcript or subtitle editor, a code or log viewer, a comments-on-text workflow. Three things it gets right that a hand-written version does not, each measured in a browser rather than assumed. First, it opens on selectionchange, not mouseup: a selection made with Shift and the arrow keys fires no mouseup at all (measured: five Shift+ArrowRight presses, zero mouseup events), so a mouseup toolbar is one keyboard users never see. It is not debounced either, because the browser already coalesces — three selection changes inside one task produced a single event, and a full click-drag produced three, so a debounce only makes the toolbar trail the highlight. Second, it is anchored to a line of the selection rather than to its bounding box: a selection spanning two lines returns one rect per line, and the union of those is the width of the column, so centring on it puts the toolbar over text the reader can see is not selected (measured: a selection whose visible first line started at x=101.8 had a union starting at x=29). Third, it keeps up with a selection inside a scrollable pane, because its scroll listener is in the capture phase — a nested scroller's scroll event does not bubble, and of a window-bubble, window-capture and document-bubble listener, only the capture one heard an inner pane scroll by 30px. It also refuses to open for text selected inside an <input> or <textarea>. That selection fires selectionchange and makes the document selection report the field's own text, while being collapsed and having no client rects — the trap that makes a naive toolbar flash over every search box with nowhere to point. Mousedown is prevented on the toolbar so the caret stays where it was: measured, pressing a button never destroyed the selection, but without the guard focus moved out of a contenteditable and onto the button. The selected text is captured when the toolbar opens rather than read in the click handler, because focusing an input wipes the document selection outright — so an action that opens a comment box would otherwise lose the thing it was about. Accessible by construction: a role="toolbar" with one tab stop and arrow-key movement between the actions, Escape to dismiss, focus-visible rings, labels for icon-only buttons, and shadcn tokens throughout so it follows the theme in light and dark. Optionally scoped to one element, so "quote this" is not offered for text selected in the site navigation. Common asks it answers: "selection toolbar react", "floating toolbar on text selection", "medium style highlight menu", "text selection popup react", "show a button when text is selected", "bubble menu without tiptap", "selection toolbar shadcn", "highlight text and show options", "quote selected text", "ask ai about the selected text", "react hook for window.getSelection", "useSelection hook", "position a popup over a text selection", "getClientRects multi-line selection", "selectionchange in react", "selection toolbar disappears when i click it", "keep focus in contenteditable when clicking a toolbar button", "text selection toolbar keyboard accessible", "shift arrow selection fires no mouseup", "selection toolbar inside a scrollable div", "toolbar drifts when the page scrolls".
A share control that opens the device's native share sheet and quietly degrades to copying the link where that sheet does not exist. Reach for it wherever a page is worth passing on: an article or blog post, a product or listing, an invite or referral link, a public dashboard or report, a job posting, an event page, a receipt or order confirmation, a shared document or file link, a playlist or video, a profile, a support ticket a user wants to forward, or a generated QR code's target URL. Common asks it answers: 'share button react', 'web share api react', 'navigator.share button', 'native share sheet shadcn', 'share to whatsapp twitter react', 'share button with copy fallback', 'react-share alternative', 'shadcn share component'. shadcn/ui has nothing that touches navigator.share — not in button, not anywhere in its sixty-odd components — so this is written inline every time, and the inline version gets three things wrong that only show up on someone else's device. First, it assumes the API is there. navigator.share is absent on desktop Firefox, absent on any insecure origin, and present or missing on desktop Chrome depending on the OS, so the button has to have a real answer for 'no sheet' rather than throwing: here an unsupported browser, a blocked payload, or any non-cancel rejection falls through to navigator.clipboard.writeText and the label says 'Link copied', the same feedback pulld copy-button gives. Second, it awaits something first. navigator.share only resolves while the click is still the active user gesture, so building the URL or fetching a short link before calling it makes the share reject with NotAllowedError on a button that worked fine in development; this component calls share synchronously as the first thing in the handler and takes an already-resolved url prop, so pass a URL you have, not a promise. Third, it reports a cancel as an error. Dismissing the sheet rejects with AbortError, and a naive catch shows 'Sharing failed' to someone who simply closed it — here AbortError resets the button silently and reports 'cancelled' to onShare instead. The API: url (defaults to the current page URL, read at click time), shareTitle and shareText for the sheet (the native title attribute stays a tooltip), children for the resting label, timeout (default 2000ms) for how long the result state stays, and onShare(outcome) with 'shared' | 'copied' | 'cancelled' | 'failed' for analytics. Feature detection never changes what is rendered, only what the click does, so the server and the first client render agree and there is no hydration mismatch. type='button' so it will not submit a surrounding form, the result is announced through an sr-only aria-live region rather than through the icon swap, both icons are aria-hidden, and the reset timer is cleared on unmount. Styled with shadcn tokens (input, accent, ring) so it follows light and dark mode, className merges rather than fights, and the focus ring is focus-visible. One file, two lucide icons, no share library.
The settings field that records a keyboard shortcut: the user presses the combination they want and it is captured as a value you can store. Use it on a keyboard shortcuts settings screen, a key-binding or keymap editor, a command palette's "change shortcut" row, an accessibility panel, an admin or dev tool where power users rebind keys, or anywhere a command's keys are configurable per user. Common asks it answers: "record a keyboard shortcut React", "hotkey input component", "keybinding recorder", "let users set their own shortcut", "capture key combination input", "shortcut picker like VS Code / Linear / Notion", "keyboard shortcut settings UI", "press-any-key input field", "rebind hotkey component", "shortcut conflict with Cmd+W", "how to store a shortcut so it works on Mac and Windows", "shadcn keyboard shortcut input", "ショートカットキー 設定 ui", "キーバインド 変更 react", "キーボードショートカット 記録 コンポーネント", "ショートカット 割り当て 画面". shadcn/ui ships nothing that receives a key press as a setting — its kbd only draws a cap — so every shortcut settings screen hand-rolls this, and the hand-rolled version is wrong in the same four ways each time. The identity is `KeyboardEvent.code`, the physical key, never the character: record `Shift+/` by its character and the setting says "?" while the matching press reports "/", so it never fires again. Alt+n on a Mac has the same problem. Modifiers are stored as roles, not keys — `mod` is the platform accelerator, so a combo recorded on a Mac as ⌘K is read back on Windows as Ctrl+K rather than as a Command key nobody has. `matchesShortcut(event, shortcut)` ships with it, so the setting you stored can be matched at run time by the same rules that recorded it, and `shortcutTokens` emits the exact token vocabulary the keyboard-shortcuts component's `keys` accepts, so a recording goes straight into your help sheet and into kbd caps. A modifier held alone is not a combination (commit on it and no shortcut containing Shift can ever be recorded), auto-repeat is ignored so leaning on a key does not record it, and a press inside an IME composition is ignored. Recording listens on `document` in the capture phase, because your own hotkey library is listening too and a bubble-phase handler would let ⌘K open the command palette while the user was trying to record ⌘K. What capture cannot do is reach above the page, and the component says so rather than pretending: ⌘W still closes the tab and ⌘Tab still switches application whatever is preventDefault'ed, so a short reserved list (DEFAULT_RESERVED, extensible by spreading it) refuses to save those combos and names whether the browser or the operating system takes them — the user never ends up with a setting that closes their tab every time they use it. Escape cancels and Tab leaves with focus moving normally, both announced in the control's accessible name, so the field is not a keyboard trap; a window blur ends recording because the keyup for the press that moved focus never arrives, and keeping that state would record ⌘K from a bare K. The caps are aria-hidden and the control is named in words ("Keyboard shortcut: Command Shift K") because a screen reader meeting ⌘ announces "place of interest sign" or nothing at all; saves and refusals land in a role=status live region rather than only in colour. Controlled or uncontrolled, every label overridable, optional requireModifier, zero npm dependencies, shadcn tokens throughout for light and dark. Composes kbd for the caps. Distinct from kbd (draws a key cap), keyboard-shortcuts (the ? help sheet that lists shortcuts) and command-palette (⌘K launcher): this is the only one that takes a key press from the user and gives you back a value to save.
The box at the bottom of a form where somebody signs with a finger, a mouse or a stylus — and, beside it, the field where somebody who cannot draw types their name instead. Reach for it wherever a screen asks for assent that is meant to bind: a delivery or handover receipt, a rental or equipment checkout, a treatment or research consent form, a waiver and liability release, a visitor or contractor sign-in at a front desk, a timesheet or job-completion sheet a technician gets signed on a tablet, a lease or invoice approval, a school permission slip, and the light end of e-signature where a full contract platform is far more than the job needs. Common asks it answers: "signature pad react", "react signature canvas", "draw signature component", "e-signature input react", "sign here box react", "capture signature on tablet", "signature-pad alternative", "react-signature-canvas alternative", "shadcn signature pad", "shadcn signature input", "canvas drawing react component", "why is my canvas blurry", "canvas blurry on retina", "devicePixelRatio canvas react", "canvas high dpi scaling", "smooth line drawing canvas", "canvas drawing looks jagged", "pointermove skipping points", "getCoalescedEvents react", "line breaks when mouse leaves canvas", "setPointerCapture drawing", "canvas drawing scrolls page on mobile", "touch-action none canvas", "signature to png data url", "export canvas as svg", "trim whitespace around signature", "transparent signature png invisible", "accessible signature field", "signature pad screen reader", "canvas accessibility alternative", "署名 パッド react", "サイン 入力 canvas", "canvas がぼやける retina". Official shadcn/ui has nothing to build this from, and the measurement is not close: fetching all sixty-three registry entries today (sixty-two are fetchable — questionnaire is listed and 404s on both style tracks) and grepping 240 KB of source, getContext, toDataURL, toBlob, pointerdown, pointermove, setPointerCapture, getCoalescedEvents, devicePixelRatio, beginPath, lineTo, quadraticCurveTo, touch-action and signature are every one of them a zero hit. The only match for canvas at all is ten occurrences in sidebar, and every one is the Tailwind variant name offcanvas. There is no component in official shadcn that draws — not one — so an agent asked for a signature field builds it from a bare canvas and a mousemove listener, and the four things that make this hard are exactly the four it will get wrong. The first is that a canvas has two sizes and the wrong one is the obvious one. The CSS size is how big the element looks; the width and height attributes are how many pixels actually exist, and they stay at 300x150 no matter what the stylesheet says. Left alone, a phone at 3x renders the box at a third of its own resolution and scales the result up, so the strokes come out soft — and a signature is a thin line whose weight and wobble are the whole of what identifies it, which makes this the one component where blur is not cosmetic. The attributes are set to the CSS size times the device pixel ratio, capped so a 4x screen does not allocate sixteen pixels of memory per CSS pixel, and the context is set back to CSS coordinates with setTransform rather than scale — scale multiplies into the transform already there, so a pad that survives two resizes draws at 4x and the signature walks off the box. The second is that pointermove is not the pointer. One move event is delivered per animation frame, but the digitiser sampled the pen many times inside that frame and the browser keeps the ones it skipped. A quick signature is where it shows: at 60Hz a fast flick is four or five points and comes out as a zigzag, while getCoalescedEvents returns the twenty that were really seen. Safari has been observed returning an empty list, so the event itself is the fallback rather than the assumption. Sampling alone is still not enough, because the corners in a joined-up polyline are in the sample rate rather than in the hand: each sample becomes the control point of a quadratic and the curve runs through the midpoints between them, so the line is tangent to the path the hand took and has no corners of its own. During a stroke only the newest segment is drawn, so a long signature costs the same per sample as a short one. The third is that a canvas is a blank to a screen reader, and no aria-label fixes it — the label names the box, and the task is to make a mark inside it. Drawing is a pointer gesture, so a pad that only draws is a form that a keyboard, switch or screen reader user cannot complete, on precisely the documents where being unable to complete it has consequences. A typed full name is the equivalent that is already recognised in practice, so it sits beside the box as a real labelled field rather than as a fallback bolted on: either path produces a value and an image, typing renders in a script face on the canvas so a sighted user sees the mark too, and the two are mutually exclusive because there is one signature. required is handled the same way round: it goes on the typed field while nothing is signed and lifts the moment something is drawn — never on the hidden input, which is barred from constraint validation outright, so the attribute parses, the browser ignores it, and the form submits unsigned. The fourth is that the line breaks when the hand leaves the box, which is where the descender of a real signature goes. setPointerCapture keeps the events coming until the pointer lifts, wherever that happens; the capture is checked before being released, because releasing one that pointercancel already took throws; a second finger landing mid-stroke is ignored rather than allowed to overwrite the stroke in progress; and touch-action is none, or the first downward stroke on a phone scrolls the page instead of drawing while the browser waits to find out which was meant. Strokes are kept as data, not as pixels, which is what makes the rest work. Undo is a slice. A resize — a sidebar opening, a tab becoming visible, a container query firing, none of them a window resize — is observed with a ResizeObserver and replayed, where a pixel-only pad loses the signature to the attribute assignment that resizes it. And the export is resolution-independent: signatureToSvg emits a real SVG document, because a signature is stored small and shown large, printed onto a contract or scaled into a PDF, and a raster of a 160-pixel box has one resolution forever. trim crops to the ink so a stored signature is not mostly empty box, with the bound taken over the Bézier control points — a quadratic stays inside the triangle of its own three, so it is exact without solving the curve — plus half the pen width, without which the trim slices the outermost stroke in half. toDataURL is there for the APIs that want a raster, and its background is documented rather than assumed: a transparent PNG of black ink is invisible the moment it lands on anything dark. Every string that reaches the SVG is escaped, since a name is free text and a document built by concatenation is the oldest bug there is. The API: value or defaultValue as a discriminated { type: "drawn", strokes, width, height } or { type: "typed", name } — the drawn form carries the box it was drawn in, because strokes are CSS pixels and without the box they cannot be laid out again on the receipt screen that shows them later. Plus onChange, penColor which defaults to the theme's own text colour, penWidth, height, maxPixelRatio, disabled, allowTyped, required, and name to post the signature through a plain HTML form as an SVG data URL. The ref exposes clear, undo, isEmpty, getValue, toSVG and toDataURL for a submit handler. strokeGeometry, strokePathData, signatureBounds, isSignatureEmpty, signatureToSvg and backingSize are exported as pure functions, so a stored signature can be rendered on a server that has no canvas at all. Within pulld it is the first component that draws: file-dropzone takes an image in and upload-list lists what arrived, but nothing until now made a mark. It sits next to type-to-confirm, which is the other way a screen asks somebody to mean it — typing a phrase to authorise a destructive action, where this captures assent that gets stored and shown back. One file, zero dependencies, not even an icon, and every colour is a shadcn token, so light and dark follow on their own.
A URL slug field that fills itself in from a title and then gets out of the way. Use it wherever a record needs a URL: the permalink or slug field in a blog post editor or CMS admin form, a page or docs route segment, a product handle, a workspace or team URL, a category or tag slug, a public profile handle. Type a title, watch the slug appear as kebab-case, and edit it whenever you want — this is the part hand-rolled fields get wrong. It keeps deriving only while the field still holds exactly what it generated, so editing the slug by hand, or loading an existing slug from your database, stops the derivation for good: renaming a published post cannot silently change its URL. Leave the field empty and blur, and it goes back to following the title. Every keystroke is sanitised in place — lowercased, spaces and punctuation collapsed to a single hyphen (or underscore), accents folded away — while the caret stays exactly where you were typing, which is what breaks when you naively assign a transformed value back to a controlled input. Unicode is handled rather than mangled: NFKD folding turns "Café au lait" into cafe-au-lait, "Łódź" into lodz, and the letters decomposition leaves whole are spelled out ("Straße" becomes strasse, not strae). Pass allowUnicode to keep the title's own script instead — without it a Japanese, Chinese, Korean, Greek, Cyrillic, Hebrew or Arabic title slugifies to an empty string, and with it combining marks stay attached to their letter, so がっこう does not quietly become かっこう. maxLength cuts a generated slug back to a whole word rather than mid-syllable, apostrophes disappear instead of splitting words ("don't panic" becomes dont-panic), and pasting a full URL takes just its last path segment. Zero dependencies, one import, shadcn tokens, optional prefix like example.com/blog/ wired to the input with aria-describedby. Common asks it answers: "slug input react", "auto generate slug from title", "permalink field component", "slugify input react", "url handle field", "kebab case input", "seo friendly url input", "slug field cms form", "react slug generator component", "github-slugger alternative", "slugify npm alternative", "editable slug with auto fill", "stop slug changing after publish", "slug input with prefix", "unicode slug transliteration react", "japanese title to slug". Typing with an IME is handled rather than fought: while a composition is open the field shows exactly what the engine put there and sanitises nothing, because transforming a half-formed character writes the old text back and loses the composition — and because not every engine fires compositionend before blur, leaving the field mid-word still commits what was on screen. Distinct from the other text fields here: masked-input imposes a fixed shape on what you type, char-counter counts toward a limit, floating-label-input is about the label, and inline-edit turns text into a field on click — none of them derive their value from another field, which is the whole of this one. Official shadcn/ui has no slug or permalink field — its input is a bare element and input-group is an assembly kit with no logic in it — and a slugify npm package solves the string, not the field: the caret, the do-not-stomp rule and the typing-in-progress state are what this component is.
Drag-to-reorder list: grab a row's grip handle and drop it in a new place — and do the same thing from the keyboard, which is the half that is normally missing. Reach for it whenever the order itself is the data: reordering tasks or a to-do list, ranking priorities, choices or search results, arranging table columns, form fields or a form builder's questions, dashboard widgets, nav and sidebar links, playlist tracks, an image or gallery order, quiz question order, steps in a workflow, checklist or recipe, saved filters and views, a queue of jobs, and the cards inside one kanban column. Common asks it answers: "sortable list react", "drag and drop list react", "reorderable list", "drag to reorder", "drag handle list", "reorder items react", "draggable list order", "move item up and down", "shadcn drag and drop", "shadcn sortable", "sortable list without a library", "drag and drop with no dependencies react", "dnd-kit alternative", "@dnd-kit/sortable simpler", "react-beautiful-dnd replacement", "react-beautiful-dnd is deprecated what now", "react-sortable-hoc alternative", "SortableJS react", "framer-motion Reorder alternative", "accessible drag and drop react", "keyboard accessible reorder list", "drag and drop is not keyboard accessible", "screen reader drag and drop announcements", "aria-live drag and drop", "roving tabindex list", "html5 drag and drop does not work on touch", "drag to reorder on mobile", "reorder list with different row heights", "next.js drag and drop list", "並び替え ドラッグ react". Official shadcn/ui ships nothing that reorders, and it is not close: across all sixty-three of its registry entries (sixty-two fetchable; questionnaire is listed but 404s), sortable, draggable, dnd, reorder, dragstart, onDragStart and pointerdown are every one of them a zero hit — and so are aria-live and roving, which is the other half of the problem. So this gets hand-rolled each time, and the keyboard is what gets dropped, because a mouse-only reorder looks finished. Here the whole interaction works without a mouse: Tab reaches the list once through a roving tabindex, arrow keys walk it, Space or Enter picks a row up, arrows move the picked-up row, Space or Enter drops it, Escape puts it back where it started, and each step is spoken through an assertive live region ("Picked up Design review. Position 2 of 5."). Every announcement — the handle label, the instructions, and the grabbed, moved, dropped and cancelled sentences — is an overridable function, so it translates. Dragging is plain pointer events: no dnd-kit, no react-dnd, no HTML5 drag-and-drop. That is what makes touch work at all (the HTML5 API has never fired on a phone), and it is why rows of different heights land exactly where they look like they will — every row is measured once at the start of a drag and displaced with transforms rather than guessed at from a uniform row height. Controlled: pass items ({ id, label } plus whatever else you carry) and persist the array onReorder hands back; renderItem draws the row body beside the handle and is told the row's index and whether it is being dragged or grabbed, and itemClassName styles the row. Within pulld it is the ordering counterpart to upload-list and bulk-action-bar, which act on rows rather than arrange them, and to tree-view, which shows a hierarchy rather than a sequence. One file, and its only dependency is lucide-react for the grip icon.
One sortable column heading for a table you already have: press it and the rows re-order, press it again for the other direction, press it a third time and the table goes back to the order it arrived in. Reach for it wherever a list of rows is long enough that people want it arranged their own way — an admin users or accounts table, an orders, invoices or billing-history list, search results, a dashboard's data table, a logs or audit-trail view, a file or document browser, a products or inventory grid, a leaderboard or ranking, a tickets/issues queue, transactions, and any report with a date, size, count or amount column. Common asks it answers: "sortable table header react", "table column sorting react", "click column header to sort", "sort table by column react", "shadcn sortable table", "shadcn table sorting", "shadcn data table sort without tanstack", "tanstack table alternative for just sorting", "react-table is too much for one column", "sort indicator arrow table header", "three state sort asc desc none", "how to clear sorting in a table", "reset table sort to default order", "aria-sort react", "accessible sortable table", "screen reader table sorting", "aria-sort not announced", "two columns both say ascending", "table header button accessibility", "th onclick not keyboard accessible", "sort numbers in strings correctly", "Item 10 sorts before Item 2", "natural sort react", "localeCompare numeric true", "Intl.Collator sort table", "accented names sort to the bottom", "null values first when sorting descending", "empty cells at top when sorting", "sorting mutates state array react", "table not re-rendering after sort", "テーブル ソート 見出し react", "並び替え カラム". Official shadcn/ui gives you nothing to start from here, and the measurement is not close: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 211,725 bytes of source, aria-sort, ariaSort, onSort, sortable, sortBy, sortDirection, ascending, descending, localeCompare, Intl.Collator, toSorted, ArrowUpDown and ChevronsUpDown are every one of them a zero hit. Its table component is 2,859 bytes of six styling wrappers in which the word sort never appears once. So every sortable table gets hand-rolled, and three things go wrong each time. The first is that the sort has two states when it needs three. Flipping between ascending and descending quietly takes something away: once a column has been pressed, the order the table came in — almost always the meaningful one, newest first, or a relevance rank the server computed — cannot be got back. There is no third press, no button that says stop sorting, and reloading the page is the only way out, which is exactly what people do. Here the cycle is first direction, opposite, gone, and gone hands back null so you render the rows as they came. Which direction comes first is per column, because ascending is the wrong first guess for a date, a size, a count or a score, where the first press is meant to mean newest or biggest and answers with the oldest and smallest instead. The second is aria-sort, which is not an attribute saying a column can be sorted — it says how the table is ordered right now, so it belongs on exactly one header at a time and reads none on the others. Held as a flag on each header, the failure always has the same shape: pressing a second column sets the new one and forgets to unset the old, and a screen reader is told two different columns are each sorting the table, which looks perfectly fine on screen. This takes the whole table's sort state as one value and derives each header's share of it, so that state cannot be represented. It also puts the attribute on the <th> rather than on the button inside it: aria-sort is defined for columnheader, and on a role=button it is silently dropped — it validates, it looks done, and it announces nothing. The third is the hit area. Putting onClick on a <th> gives a table that sorts with a mouse and for nobody else: nothing to tab to, nothing answering Enter or Space, and a cell that gives no hint it does anything, because a <th> is not interactive and no amount of ARIA makes it so. The heading here is a real button stretched across the cell — the cell gives up its padding to it — so it gets the keyboard, the focus ring and the announcement for free, and the pressable area matches the thing that looks pressable. Sorting a table also announces nothing on its own: the rows are replaced, focus has not moved, and aria-sort changing on an element is not an event any reader speaks. So a press puts "Table sorted by Name, ascending." through a polite live region — from the header that was actually pressed, not from the one that just lost the sort — and clears it again a moment later, since a live region inside a header cell would otherwise become part of that cell's own content and be read back on every future visit. The comparison is exported separately as sortRows, compareValues and isBlankValue, because the browser and the server that paginates the same table have to agree on what the order is. Descending is the negated comparison, never the ascending result reversed: reversing floats every empty cell to the top the moment the arrow flips, and scrambles the rows that tied on the way up, so the same three Pending rows appear in a different order each way and the table reads as if it is shuffling itself. Blank cells — null, undefined, empty string, NaN, an invalid Date, but not 0 and not false — stay at the bottom in both directions. Text goes through Intl.Collator with numeric ordering, so Item 2 comes before Item 10 and Ångström does not land below Zulu where nobody scrolls; dates compare as instants and booleans as false-then-true. It never sorts the array you pass it, which is the bug that renders nothing at all, and it is meant to be run over the rows the server sent rather than the ones already on screen — sorting what is displayed lets the previous sort survive inside every group of ties, so the table depends on the order of presses rather than on its state. A disabled heading keeps its tab stop and ignores presses instead of taking the disabled attribute, which would drop it out of the tab order and lose a keyboard user's place the moment the table starts loading. align="end" right-aligns a numeric column and moves the arrow to the label's left so the heading stays flush with the figures beneath it, and the unsorted double-chevron is visible at rest rather than on hover, because hover does not exist on a phone and it is the only thing saying the column can be sorted at all. useSortHeader, nextSortState and ariaSortFor are exported for a header laid out as divs or one that already holds a filter menu. Within pulld it is the table counterpart to sortable-list, which drags rows into an order a person chooses rather than computing one, and it sits beside scroll-shadow and virtual-list, the other two pieces a long table wants. One file, one column, your <table> stays yours, and its only dependency is lucide-react for the arrows.
An inline SVG trend line — a sparkline — that shows the shape of a series in about the space of a line of text. Use it when you need a chart small enough to live inside something else: a 7-day or 30-day trend next to a KPI in a stat card or dashboard tile, a per-row usage or activity graph in a table (requests, spend, errors, signups, page views), a mini price or metric history, a tiny “last N days” graph in a list item, or any micro / inline / thumbnail chart where axes, gridlines, a legend and a tooltip would just be noise. It renders as a plain <svg> with no hooks, no state and no effects, so it works unchanged inside a React Server Component, in a static export, and with JavaScript disabled — there is no “use client” in the file. Different from shadcn/ui’s official chart, which is a ~10KB wrapper around Recharts (it declares recharts@2.15.4 as a dependency and also pulls in card) meant for full charts with axes, tooltips and legends: this is one zero-dependency file that draws a single path and needs nothing but your cn util. Different from gauge and progress-ring, which draw one current value as an arc rather than a series over time. It handles the parts hand-written sparklines get wrong: null, undefined and NaN entries are treated as gaps that keep their slot on the x axis and break the line, instead of being dropped (which slides the rest of the series sideways) or drawn as zero (which invents a crash that is not in the data); a flat series is centred rather than dividing by zero and emitting a NaN path that silently renders nothing at all; the plot area is inset by half the stroke so the highest and lowest points are not sliced in half by the viewport edge; vector-effect=“non-scaling-stroke” keeps the line an even weight when the SVG is stretched across a wide table cell, and the last-value dot is drawn as a round line cap so it stays a circle instead of being squashed into an ellipse by that same stretch. Pass min and max to pin the scale so a whole column of sparklines is actually comparable — autoscale every row to its own extremes and they all end up looking like the same shape. It also ships an aria-label generated from the data (“12 points, up from 3 to 91, low 3, high 94”), where shadcn’s own chart.tsx sets no role=“img” or aria-label of its own; pass your own aria-label to override it, or aria-hidden when a surrounding stat card already announces the number. Props: data, width, height, min, max, strokeWidth, area, showLast, formatValue.
A press-to-dictate microphone button that fills a text field by voice, and stops claiming to listen the moment the browser has stopped. Reach for it beside any field somebody would rather speak than type: a search box, a comment or reply box, note and journal fields, a message composer, contact and support forms, meeting and consultation notes, inspection and field-service reports filled in on a phone with gloves or dirty hands, delivery and warehouse notes, clinical and veterinary notes, incident and maintenance logs, recipe and shopping lists, long-form description fields in a CMS or listing flow, translation and language-practice inputs, and anywhere dictation is the accessible alternative for someone who cannot comfortably type — motor impairment, RSI, a broken wrist, or simply a phone in one hand. Common asks it answers: "speech to text react", "voice input component", "dictation button", "react speech recognition component", "microphone button for input field", "web speech api react hook", "voice typing textarea", "speech to text search box", "react-speech-recognition alternative", "useSpeechRecognition hook", "voice dictation shadcn", "shadcn microphone input", "talk to type react", "record voice fill form", "webkitSpeechRecognition react", "speech recognition stops after a few seconds", "speech recognition keeps stopping", "continuous speech recognition restart onend", "speechrecognition onend fires by itself", "mic button still says listening after it stopped", "speech recognition duplicate words", "speech recognition repeats the first word after a pause", "resultIndex speech recognition", "interim results overwrite the input", "webkitSpeechRecognition is not defined", "speech recognition not working in firefox", "speech recognition safari support", "SpeechRecognition not-allowed", "microphone permission denied react", "allow microphone in an iframe", "microphone blocked on http", "voice search input react", "mic button shadcn", "hands free form input", "dictate into a textarea", "accessible alternative to typing", "音声入力 react", "音声でテキスト入力", "マイクボタン 文字起こし". Official shadcn/ui has nothing for this and no combination of its parts reaches it: input and textarea are the fields themselves and know nothing about audio, button is a button, and there is no speech, microphone or recording primitive anywhere in the library. Distinct from every other -input component in this registry — otp-input, tag-input, masked-input, phone-input and the rest are the field, while this one sits beside a field you already have and writes into it, so it composes with any of them. The component turns on one fact that every hand-rolled version gets wrong: recognition ends by itself. The browser stops a session after a stretch of silence, and after a while regardless, and the only thing it tells your code is an end event. A button that tracks the boolean its own click set therefore keeps a pulsing red dot and the word Listening over a microphone that was handed back a minute ago, and the user keeps talking into nothing. Here every visible state is driven by the platform's own start, end and error events, the setting the user asked for is kept separate from whether audio is actually being captured (aria-pressed carries the first, data-active the second), and continuous mode starts a fresh session when the browser ends one — under a budget, so a machine with the microphone muted or a laptop that has gone offline cannot turn that into a hot loop, and so a genuine pause in the middle of a paragraph costs nothing. The restart also resets the result cursor, because a new session numbers its results from zero and a cursor carried over from the last one silently swallows the first words after every pause. Interim results are kept strictly out of the value: the service rewrites its guess as more audio arrives, so writing it into the field the user is editing changes their content under them and fills their undo history with words nobody typed — the guess is shown beside the button as a preview and only confirmed text is ever appended. Appending is its own small problem and is solved and exported as appendTranscript, because value + transcript welds every chunk onto the previous word and padding unconditionally with a space yields a stray gap before a dictated full stop. Feature detection reads the prefixed constructor as well as the standard one, since webkitSpeechRecognition is the spelling the browsers that actually ship this expose, and a missing API is a first-class state with its own copy rather than a dead button. The not-allowed error is untangled rather than taken at face value: it means four different things — an http origin, an iframe without allow="microphone", a stored block, and a prompt closed without an answer — and only the third is worth sending someone to their site settings for, so the other three say something true instead, and a permission changed in those settings is picked up live through the Permissions API change event rather than staying dead until a reload. Stop asks the service to deliver what it is still holding instead of aborting and losing the last thing that was said, and unmounting detaches the handlers and aborts, so navigating away inside a single-page app cannot leave the recording indicator lit with no control left that could turn it off. The recognition language is resolved from the document rather than left to the user agent, which is the one setting whose behaviour is not defined across browsers. Worth knowing before shipping it somewhere sensitive: the specification permits the audio to be sent to a remote service — the existence of a network error code is the platform admitting as much — so a dictated field may be data that has left the device. Ships as a hook (useSpeechInput) plus a button, controlled by pairing value with onValueChange or left to report through onTranscript, marked aria-disabled rather than disabled so a refusal stays reachable and can explain itself, with a permanently mounted polite live region for status and failures. Styled entirely with shadcn tokens (input, accent, ring, muted-foreground, destructive), so it follows light and dark mode, and its only dependency is lucide-react for the icons.
An inline loading indicator that announces itself: a spinning lucide Loader2 inside a role="status" live region with a screen-reader-only label, so a pending operation is heard as well as seen. Reach for it while fetching data, submitting a form, loading a page or a section, as a Suspense or lazy-route fallback, beside a disabled control, inside a table cell or panel that is still filling in, or anywhere you would otherwise drop a bare "Loading…" string. Common asks it answers: "loading spinner", "react spinner component", "loader component", "busy indicator", "activity indicator", "throbber", "accessible loading state", "aria-live loading announcement", "screen reader loading", "Suspense fallback spinner", "spinning Loader2", "animate-spin loader". Also asked as "shadcn spinner", "next.js loading.tsx spinner", "app router loading state", "tanstack query isLoading spinner", "swr loading indicator", "react-spinners alternative", "spinner without a package", "button loading state react", "disabled button while submitting", "server action pending indicator", "useFormStatus pending spinner", "spinner inside a table cell", "loading state for a dialog", "ローディング スピナー react", "読み込み中 表示", "送信中 ボタン", "スクリーンリーダー 読み込み 通知". Reach for a skeleton instead when the shape of what is coming is already known and the wait is long enough to look empty — a spinner is for a wait whose layout you cannot pre-draw, or one short enough that a skeleton would flash. The usual hand-rolled version — a bare Loader2 with animate-spin dropped straight into the markup — is invisible to assistive technology: the icon is decorative, so nothing is announced and a screen-reader user waits in silence. Official shadcn/ui now ships a spinner of its own, so choose deliberately rather than by search rank: theirs is the Loader2 icon itself carrying role="status" and the hard-coded English string aria-label="Loading", which is announced but cannot be changed without overriding the attribute, and its registry entry adds class-variance-authority to your package.json. Here the icon is aria-hidden and the announcement comes from a real sr-only text node in a wrapping live region, "Loading" by default; set label to say what is actually loading ("Loading invoices") so the same component announces something useful on every screen, and so the string sits where a translation pipeline can find it rather than inside an aria attribute. Take theirs if you want the icon element itself and nothing around it; take this one if the announcement has to say more than "Loading". It is 1rem square and inherits the current text colour, so it sits correctly inside a button, a link, or a line of muted text with no extra styling, and every span prop (id, style, className, data-*) passes straight through to the wrapper. Pick the sibling that matches the shape: loading-button for a button whose own label swaps to a busy state, progress-ring or gauge when the percentage is known — this is the indeterminate "something is happening" case. Styled with shadcn tokens for light and dark themes; lucide-react is the only dependency.
One default action with its variants folded under the arrow beside it: a wide button that does the obvious thing, and a narrow one next to it that opens a menu of its near-relatives. Reach for it wherever a screen has an obvious action and two or three cousins of it — Save and Save as draft, Merge and Squash and merge and Rebase and merge, Deploy and Deploy to staging and Roll back, Export CSV and Export XLSX, Send and Schedule send, Invite and Copy invite link, Publish and Publish privately, Download and Download as PDF, Reply and Reply all, Run and Run with options, Approve and Approve with comment, Delete and Delete and archive, Add and Add from template, Print and Print selection — anywhere the default is right nine times out of ten and the tenth needs a different one rather than a different screen. Common asks it answers: "split button react", "shadcn split button", "shadcn button with dropdown", "button with arrow dropdown react", "button with chevron that opens a menu", "primary action with secondary actions react", "default action plus more options button", "save and save as draft button", "merge button with dropdown like github", "deploy button with options", "export button with format menu", "send later button", "shadcn button-group with dropdown-menu example", "combine button and dropdown menu shadcn", "split button without radix", "dropdown button no dependencies", "menu button pattern react", "aria-haspopup menu button react", "accessible dropdown button", "two buttons joined together css", "remove double border between adjacent buttons", "button group double border 2px", "rounded corners between two joined buttons", "focus ring clipped by next button", "focus ring cut off in button group", "focus-visible z-10 button group", "no visible divider between filled buttons", "split button divider not showing", "button group rtl broken corners", "split button keyboard navigation", "arrow down should open the dropdown", "menu opens but arrow keys do nothing", "focus first menu item when the menu opens", "menu button focus management", "escape should close the menu and give focus back", "focus lost after closing a dropdown", "focus jumps to body when menu closes", "tab starts from the top of the page after closing a menu", "tab out of an open dropdown menu", "dropdown trigger submits my form", "button inside form submits instead of opening menu", "type=button on dropdown trigger", "chevron button has no accessible name", "icon button announces just button", "two buttons both read as button screen reader", "aria-label for the dropdown arrow", "name the arrow of a split button", "disabled menu item still focusable", "aria-disabled vs disabled menu item", "arrow keys skip my disabled menu item", "menu arrow keys should wrap around", "primary action disabled but menu still usable", "分割ボタン react", "ボタンの隣に矢印でメニュー", "既定の操作とその他の操作", "保存と下書き保存のボタン", "ドロップダウン付きボタン shadcn", "矢印キーでメニューを開く", "Escape でフォーカスを戻す", "メニュー項目を無効にしても読み上げたい". Official shadcn/ui ships both halves and not the thing they make. Measured across its whole registry today — 63 items listed, 62 fetchable, `questionnaire` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `SplitButton`, `split-button`, `splitButton`, `primaryAction` and `defaultAction` are every one of them a zero hit, and the route it documents is to compose `button-group` with `dropdown-menu` yourself. That is a real route, and `button-group` is real help: `[&>*:not(:first-child)]:rounded-l-none`, `[&>*:not(:first-child)]:border-l-0` and `[&>*]:focus-visible:relative [&>*]:focus-visible:z-10` between them collapse the doubled corner radius and the doubled border, and lift whichever child has focus so its ring is not painted over by the sibling that comes after it in the DOM. That last one is worth measuring rather than believing: in Chrome, two adjacent buttons with a 2px ring on the first draw the ring on three sides, and the same pair with `relative` and `z-10` on the focused half draws it on four — the ring extends 2px past the first button's border box, the sibling's box begins at exactly that edge, and `document.elementFromPoint` at that pixel answers with the sibling, whose background is painted later. Nothing is clipped and nothing errors; the ring just stops on the one side that says which half has focus. Two things are left over. The first is the divider, and it is left over precisely because the group did its job: of official's own `button` variants only `outline` carries a border, so `default`, `destructive` and `secondary` enter a group with none at all and `border-l-0` removes nothing — two filled halves then butt together as one unbroken block of colour, and where to press for the action and where to press for the menu stop being distinguishable. A filled split button needs a divider drawn in the foreground colour at low alpha, which is the opposite of what the group's rule does, and an outlined one needs one shared border rather than two. The second is every piece of the behaviour, and `dropdown-menu` does not write it either — it hands it to `radix-ui` as an npm dependency, 8,408 bytes of thin wrappers over `DropdownMenuPrimitive`. So grepping official for the aria proves nothing in either direction: `aria-haspopup`, `role="menu"` and `menuitem` are zero hits across all 62 items because Radix supplies them at runtime. What the search does establish is that no file in the registry contains a split button's keyboard — `ArrowDown` occurs once in the whole registry, in `message-scroller`, and the single `aria-expanded` is the `has-aria-expanded:bg-muted/50` styling hook on `TableRow`. It is one thing to look at and two things to press, and everything here is a consequence of that sentence. Focus stops twice, so the arrow needs a name of its own, and it has to carry the default action: it is a chevron with no text, so left to itself it announces the word "button" one position away from another button that also announces "button", and a toolbar with three split buttons becomes three identical "More options". Here the name is built from the label — "More Save options" — and `labels.more` rewrites it in any language. Down and Up on the default action open the menu too, and they land inside it: opening while leaving focus on the default action is the version that looks right and is useless, because the keys that drive a menu are handled in the menu and the next Down does nothing, while handing focus to the arrow first is the other near-miss, since the menu is already open and the person's next Down walks from the arrow into the menu and skips the first item. Escape, Tab and choosing an item all put focus back on the half that opened the menu, which is not always the arrow; the element standing where they are is about to be removed, and a browser does not move focus when that happens — it drops to `<body>`, so the next Tab starts again from the top of the page. Tab is not prevented, because Tab means leave, but focus is moved onto the opener before the menu goes, so the browser computes the next stop from an element still in the document. A press outside is the one exception to all of that: the person has already said where they want to be, and pulling focus back would undo their click. The arrow is `type="button"` and no prop can change it, because a split button lives in a form more often than not and `<button>` in a form defaults to submit — an arrow that inherited the default would post the form the moment somebody went looking for the second option; `type` reaches the default action, which is the half that may legitimately want to submit. An unavailable item gets `aria-disabled` and never the `disabled` attribute: `disabled` takes a button out of the focus order, so arrowing a menu of four whose third is unavailable walks 1, 2, 4, and the person learns not that the item is unavailable but that it does not exist. The arrow keys wrap rather than clamp, which is the menu convention and the opposite of the listboxes elsewhere in this registry — three items clamped make the last one a key that does nothing, which reads as the keyboard having broken. And the flat corners are logical (`rounded-e-none`, `rounded-s-none`) rather than left and right, so the unit mirrors itself on an Arabic or Hebrew page instead of coming apart. What you get: `SplitButton`, the whole unit, with `label` required because it is both the default action's text and the source of the arrow's accessible name; `actions`, the variants, each `{ label, onSelect, disabled?, icon?, id? }`, where an empty list renders no arrow at all because an arrow that opens an empty panel is a dead end; `variant` of `primary`, `outline` or `destructive`, each with the divider its own fill needs; `align` of `end` or `start` for which edge the menu lines up with; and the three ways to make something unavailable kept separate — `disabled` for both halves, `actionDisabled` for the default action alone with the menu still open for business, which is the combination the whole pattern exists for (a form that cannot be saved yet but can be saved as a draft, a pull request that cannot be merged but can still be closed), and `menuDisabled` for the arrow alone. `nextMenuIndex` is the wrap rule as a plain function, so the one place this differs from a listbox can be asserted on its own, and `defaultSplitButtonLabels` is the arrow's name as data. `onOpenChange` is reported from an effect rather than from inside the state setter, so a caller is told once per change instead of twice under strict mode. Per-part `className` for the wrapper, each half, the panel and the items. Nothing is portalled and nothing measures the viewport: the panel is absolutely positioned inside the wrapper, which is what a two-to-five item menu under a button needs — a menu that has to flip above the trigger near the bottom of a scroll container wants a positioning library, and that is a different component. One file, one lucide icon, and no `radix-ui`. Distinct from the neighbours: `confirm-button` is one action that asks twice, `loading-button` is one action that is busy, and `share-button`, `print-button` and `csv-export-button` are each a single action — none of them has a second half. `command-palette` is a searchable dialog over everything, reached by a shortcut rather than by a button. `multi-select`, `country-select`, `language-select` and `currency-select` are listboxes: they pick a value, they are named by a field label, and they clamp at the ends. This is the first `role="menu"` in the registry — `menuitem` appears in none of the other 110 components — and a menu of actions is the one popup where the items are buttons that do something rather than options that select something. `bulk-action-bar` acts on a selection, and `keyboard-shortcuts` is the sheet that lists what the keys do.
The single-number tile at the top of a dashboard: a label, one big value, and an optional percentage change with an up or down arrow — green when the number moved the right way, red when it did not. Use it wherever a screen opens with a row of headline figures: an analytics or metrics dashboard, an admin overview, a KPI or scorecard row, a billing and usage summary, a SaaS home screen, a revenue or traffic report. Common asks it answers: "stat card", "metric card", "KPI card", "metric tile", "summary card", "big number card", "dashboard stat tile", "stats card react", "shadcn dashboard cards", "tailwind stat card", "number card with percentage change", "card with trend indicator", "percentage change badge", "revenue card with trend arrow", "analytics summary cards", "stats row", "show total users with growth", "MRR / ARR card", "active users card", "conversion rate card", "Next.js dashboard stats", "Stripe/Vercel-style dashboard tiles". shadcn/ui ships card as an empty container with no notion of a metric, so the value typography, the delta colouring and the arrow are hand-rolled on every dashboard. Pass `label`, `value` and optionally `delta` (a number: positive renders the up arrow, negative the down arrow, and omitting it renders no delta at all) plus a `hint` line for the comparison period, e.g. "vs. last month". `value` is a ReactNode, not a string, so a pre-formatted currency or an Intl.NumberFormat result drops straight in and the component never guesses at your locale or currency. The direction is not left to colour alone: the arrow is aria-hidden and an sr-only "Up"/"Down" is spoken before the number, so the tile still means something to a screen reader and to a red-green colour-blind reader, which a bare green percentage does not. Composes into a responsive grid to form the stats row, and pairs with gauge and progress-ring when the figure is a ratio rather than a total. Styled with shadcn tokens (card, muted-foreground) with an explicit dark-mode pair for the delta colours; lucide-react is the only dependency. One thing it deliberately does not decide for you: `delta` is read as "up is good", so a positive number is always green. For a metric where rising is bad — churn, bounce rate, error rate, p95 latency, refunds, open incidents, cost per acquisition — pass the change negated (a 2-point rise in churn as `-2`) or wrap the card, rather than expecting it to know which way your metric should move. Distinct from feature-card, which sells a capability with an icon and copy: this one carries a live number.
Horizontal stepper that shows where someone is in a fixed sequence — numbered circle markers joined by a connecting line, each drawn as complete (filled, with a check), current (ringed and highlighted) or upcoming (muted). Pass the steps and a current index and it derives every state; there is nothing to keep in sync by hand. Reach for it at the top of anything multi-step: a checkout or cart flow, a signup and onboarding wizard, account or workspace setup, a KYC or identity-verification flow, a document or tax filing, a multi-page form split across screens, an upload-then-review-then-publish pipeline, a survey or quiz, or an installer. Common asks it answers: "stepper component", "step indicator react", "multi-step form progress", "wizard steps ui", "checkout progress bar with steps", "onboarding progress indicator", "shadcn stepper", "progress steps 1 2 3", "form wizard header", "onboarding steps", "onboarding progress react", "signup flow progress", "step by step progress ui", "horizontal stepper react", "numbered steps component", "progress tracker steps", "step rail", "breadcrumb style steps", "step 2 of 5 indicator", "show which step the user is on", "wizard navigation header", "checkout steps cart shipping payment", "MUI Stepper equivalent shadcn", "antd Steps shadcn", "Mantine Stepper alternative", "Chakra Steps equivalent", "react-step-wizard alternative", "headless stepper react", "tailwind stepper", "accessible stepper aria-current step", "stepper screen reader completed current", "clickable stepper go back to a previous step", "prevent jumping ahead in a wizard", "ステッパー react", "ステップ インジケーター", "複数ステップ フォーム 進捗", "ウィザード 現在のステップ", "申し込みフロー 進捗表示", "オンボーディング 進捗", "ステップ 2/5 表示", "完了・現在・未完了 の表示", "aria-current=step ステッパー". shadcn/ui has no stepper: its progress component is a single bar with no notion of discrete stages, labels or a current position, and while its questionnaire is a multi-step flow, that component owns the questions and answers and reports its place as a plain "3 of 8" counter rather than a rail of numbered markers — so the header that shows where someone is in a sequence you already control still gets rebuilt by hand out of divs and borders. It is also not a timeline: this one counts position through a sequence that is known in advance and still to be finished, while timeline is the record of what already happened and has no current step. It is not a product tour either: a tour points at the interface and explains it, one popover at a time, while this says where somebody is in a sequence they are working through — reach for product-tour when the thing being explained is the screen, and for this one when the thing being tracked is their progress. Built as an ordered list, because the steps are an ordered list: the active one carries aria-current="step", every marker states its own status in screen-reader-only text (Completed / Current step / Not completed) rather than leaving the meaning to a colour and a tick, and the check icon is aria-hidden so it is not announced twice. Conveying stage by colour alone fails WCAG 1.4.1, which is why the status is always spelled out. Pass onStepClick and the steps already reached become real buttons with a focus-visible ring, while upcoming steps stay inert — a stepper that lets someone jump forward past validation is worse than one that is not clickable at all. Theme-aware through shadcn tokens with dark mode, and the only dependency is lucide-react for the check icon.
Keeps a long table's header row visible while the rows scroll, and freezes up to three leading columns while it scrolls sideways — Excel's freeze panes, for an HTML table — by wrapping the <table> you already have, with no change to a single <th>. Reach for it wherever a table is taller than the screen and the column names stop being visible a third of the way down: an admin users, accounts or customers list, an orders, invoices or billing-history table, a transactions or ledger view, logs and audit trails, an inventory or stock table, a reporting or analytics breakdown, a data grid on a dashboard, a pricing or comparison matrix, a spreadsheet-like editor, search results, a tickets or issues queue, and any wide table people scroll sideways through. Common asks it answers: "sticky table header react", "shadcn sticky table header", "shadcn table header not sticking", "freeze header row table css", "frozen header table react", "table header stays on scroll", "position sticky th not working", "sticky thead not working", "sticky top-0 does nothing on table header", "why is my sticky header not sticking", "sticky table header border disappears", "border bottom missing on sticky header", "sticky header rows show through", "table rows visible through header", "transparent sticky header table", "freeze first column table react", "sticky first column and header", "sticky column overlaps header", "corner cell z-index table", "pin multiple columns table", "sticky header under fixed navbar", "table header hidden behind app header", "sticky header offset fixed navbar", "scrollable table with fixed header", "table with max height and sticky header", "tanstack table sticky header", "data table sticky header shadcn", "react-table sticky header css", "freeze panes html table", "excel freeze panes react", "freeze top row and first column", "tailwind sticky table header not working", "sticky header border-collapse", "sticky thead inside overflow-x-auto", "sticky header inside a scrollable div", "shadcn table wrapper breaks sticky", "テーブル ヘッダー 固定 react", "見出し行 固定 スクロール", "表 ヘッダ 固定 shadcn", "列固定 テーブル react", "先頭列 固定 横スクロール". Official shadcn/ui has nothing here, and the measurement is not close: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 223,289 bytes of source, sticky, position: sticky, position:sticky, sticky top-0, stickyHeader and scrollLeft are every one of them a zero hit, as are getBoundingClientRect, ResizeObserver, IntersectionObserver and offsetHeight, so nothing in that registry measures an element at all. Its table is 2,468 bytes of six styling wrappers in which the word sticky never appears. So this gets hand-rolled, and four things go wrong every time — three of them invisible until you actually scroll. The first is that the border under the header disappears. Tailwind's preflight sets border-collapse: collapse on every <table>, in v3 and v4 alike, and in the collapsed model a border belongs to the table's grid rather than to the cell that declared it. The cell is what sticks; the grid is not — so border-b on a pinned <th> scrolls away with the rows and the header is left with no bottom edge, floating over the data. The line here is an ::after box, which belongs to the cell and travels with it. And the repair the search results hand you for that — flip the table to border-separate so the borders belong to the cells — is the one thing this refuses to do, because the separated model ignores borders on a <tr> and shadcn's own TableRow puts its border-b exactly there. Flipping the table trades one missing line in the header for every missing divider in the body, which is a much larger regression arriving as a fix. The second is that the header is transparent. A <th> has no background of its own, so a pinned one does not stay in front of the rows, it stays in place while they pass through it, and the heading and whatever row is underneath are painted over each other. It reads as a rendering fault rather than as a missing colour, which is how it survives review. The third is the one that produces "sticky just does not work": a sticky cell is pinned to its nearest scrolling ancestor, never to the window unless the window is that ancestor, so the question is never whether you wrote sticky top-0 but which element is the scrollport. Official shadcn/ui's own <Table> is the usual culprit — it renders <div class="relative w-full overflow-x-auto"> around the table, and a box with overflow-x: auto computes overflow-y: auto as well, so that div is a scrollport on both axes and a header pinned to it never moves, because it is the page that is scrolling. Pass maxHeight and this wrapper becomes the scrollport itself, which always works and is the only arrangement in which a table can scroll sideways and keep a pinned header at the same time; leave it off and the page scrolls, in which case nothing between this wrapper and the <table> may have an overflow. The fourth is the corner. Pinned columns need a left that starts where the previous column ended, which cannot be a static class because it is the measured width of the column before it — pinning them all at left: 0 stacks three columns in the space of one, each painting its text through the others — and a sticky cell makes its own stacking context, so a header cell and a body cell that tie on z-index are resolved by document order, which puts the body on top and slides the first column over the heading as soon as the table moves sideways. stickyColumns pins up to three leading columns with offsets measured through a ResizeObserver, and lifts the corner above both. offset takes the height of a fixed app header, without which the table header pins correctly and parks underneath the toolbar for the whole of the scroll. maxHeight takes anything CSS does — 420, "60vh", "calc(100dvh - 12rem)". Recolour the pinned cells with an important utility, className="[&_thead_th]:!bg-card", since these are descendant rules. One header row is supported: a second row of grouping headers would need its own measured offset, and pinning both at the same top would stack them. Within pulld it is what a long table wants next to sort-header, which orders one column; scroll-shadow, which fades the edges so you can tell there is more sideways; virtual-list and infinite-scroll, which deal with how many rows there are rather than where the header is; and sortable-list, which drags rows into an order by hand. One file, your <table> stays yours, and it adds no dependencies at all.
A list row that slides sideways under a thumb to reveal what can be done to it — archive, delete, mark read, pin, snooze — with a button on the row that reveals exactly the same actions, because a swipe is not a control a keyboard or a screen reader has. Reach for it wherever a list is worked through rather than read: a mail or message inbox, a chat or conversation list, notifications and an activity feed, a task or to-do list, cart and wishlist rows, saved or favourite items, an orders or deliveries screen, a support or ticket queue, a moderation queue, and any mobile admin list where a row needs an action but not a whole screen of its own. Common asks it answers: "swipe actions react", "swipe to delete react", "swipe to reveal actions", "swipeable list item react", "swipe left to delete list row", "ios mail swipe actions web", "react-swipeable-list alternative", "react-swipe-to-delete-component alternative", "swipeable-list-item shadcn", "shadcn swipe actions", "shadcn swipe to delete", "swipe actions without a library", "swipe gesture react no dependencies", "framer-motion drag row actions", "touch swipe row react", "detect horizontal swipe vs vertical scroll", "swipe breaks vertical scrolling", "list wont scroll after adding swipe", "Unable to preventDefault inside passive event listener invocation", "onTouchMove preventDefault not working in react", "touch-action pan-y", "swipe actions rtl", "swipe direction reversed in arabic", "accessible swipe actions", "swipe to delete keyboard accessible", "screen reader cannot swipe", "row click fires after swipe", "click fires when i swipe a list item", "snap open swipe row", "swipe row flick velocity", "close other rows when one is swiped", "スワイプ 削除 react", "リストを横に払って操作". Official shadcn/ui has nothing here and nothing to build it out of. Fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 211,787 bytes of source, touchstart, touchmove, touchend, touchcancel, onTouchStart, onTouchMove, pointerdown, pointermove, pointerup, setPointerCapture, releasePointerCapture, onSwipe, threshold, TouchEvent, PointerEvent, clientX, deltaX and passive are every one of them a zero hit (measured 2026-09-30): it does not read a drag anywhere. The seven hits for the word swipe are all one Tailwind class string on toast, the Radix data-[swipe=…] variants that flick a transient notification away, and the single translateX is the progress bar's own fill. Distinct from the pulld pieces it sits beside, which each take a different half of the problem: sortable-list drags a row to move it in the order rather than to act on it, pull-to-refresh reads a vertical pull at the top of the same list, bulk-action-bar acts on rows after they have been ticked rather than one at a time, toast has a swipe of its own but it dismisses a notification outright and offers nothing to choose from, inline-edit changes a row's contents in place, and confirm-button and type-to-confirm guard the destructive action this reveals. Seven things separate it from the version built out of an onTouchMove and a clientX subtraction. First, the axis is decided once, after the finger has left an 8px slop circle, and a tie goes to vertical — the opposite of the same tie-break in a pull-to-refresh, and deliberately: this component is the row inside the list, so what it can wreck is the list's scrolling, and a list that stops moving under a thumb reads as broken while a swipe that needs a second try reads as a miss. Thumbs travel in arcs, so a decided axis is never re-decided. Second, touchmove is registered by hand with passive: false, because React registers onTouchMove passively and a passive listener's preventDefault is dropped with a console warning — the row slides sideways and the list scrolls behind it, and no prop changes that. touch-action: pan-y hands vertical scrolling back to the browser while keeping the horizontal direction, which is also what gives the preventDefault something to prevent: with the default auto the browser would have started scrolling and marked the sequence non-cancelable before the axis was decided. Third, the opening direction is derived from the resolved writing direction rather than from a hard-coded minus sign: the panel is positioned with inset-inline-end so the browser places it, and on an RTL page it sits on the left and the finger goes right to open it — a component that only knows how to subtract opens the row when it is swiped shut. That direction is read at event time, never during render, because getComputedStyle does not exist on the server and a transform decided in render would be missing from the server's HTML, which React discards the whole tree over. Fourth, the gestures that are not swipes are each turned away and each leaves the row where it already was, not shut: a second finger, a vertical drag, and a move the browser has already committed to scrolling. Returning an open row to shut is the bug where the row someone just opened vanishes as they scroll to reach its buttons. A drag that begins on an already-open row continues from where it was rather than jumping to zero under the finger. Fifth, a release snaps, and speed is read before distance: a short fast flick opens a row that never travelled half the panel, which is the gesture people actually make and the one a distance-only test refuses, and a fast flick back closes one that is still more than half open. A touchcancel — an incoming call, the system's own edge gesture — puts the row back instead of sharing the release handler and revealing actions nobody released onto. Sixth, the click a touch synthesises when it ends is swallowed in the capture phase after a swipe, so the row's own handler does not open the message that was being swiped. Seventh, the actions are reachable without the gesture: a disclosure button is always on the row carrying aria-expanded and aria-controls, the panel is a labelled group, and while it is shut its buttons are out of the tab order and out of the accessible tree — completely covered by the row, a focusable button is a focus ring vanishing into a list, which is worse than no keyboard support at all. Escape closes and hands focus back to that button, and so does running an action, so nobody loses their place. Deliberately reveal-only: a long swipe does not fire the destructive action by itself, since an accidental delete is the one mistake this shape of UI makes, and an auto-commit only touch users can reach is the same inequity the component exists to fix. The drag also stops exactly where the last action ends rather than rubber-banding past it, because there is nothing further back there to see. Controlled or not: pass actions and children and it manages itself, or add open and onOpenChange to hold "which row is open" in the list's own state and get one-at-a-time behaviour. Optional side (start/end), disabled, hideToggle for callers supplying their own control, actionWidth, openRatio, rowClassName and labels to reword or translate. One file of 747 lines behind 34 unit tests, shadcn tokens throughout so it follows light and dark themes and honours prefers-reduced-motion, and lucide-react is the only dependency — no gesture library, no Radix, no react-dom import.
A table of contents for the page the reader is on, with the section they are currently reading highlighted as they scroll. Use it for the "On this page" rail beside documentation and guides, API references, changelogs and release notes, long blog posts and tutorials, handbooks, legal and policy pages, and reports. Common asks it answers: "table of contents component", "toc sidebar", "on this page nav", "scrollspy", "scroll spy in React", "highlight the active heading while scrolling", "docs right rail", "anchor link navigation", "in-page navigation", "sticky table of contents", "MDX toc", "react-scrollspy alternative". shadcn/ui ships nothing for this, and its navigation-menu and sidebar are for moving between pages, not around one. You pass the headings in as items — the shape rehype-slug, MDX and Contentlayer pipelines already hand you — so the list is rendered on the server and the links work before, and without, JavaScript; only the highlight needs the client. The awkward part is deciding which heading counts as current, and this fixes the three ways a hand-rolled one gets it wrong. The last section is normally shorter than the viewport, so its heading never reaches the activation line and the final entry can never light up — reaching the bottom of the scrollable area selects the last heading, because there is nothing further to read. A section stays current while it is being read rather than only while its heading is on screen, which is where an IntersectionObserver checking is-it-visible goes blank on any section taller than the window. And clicking an entry starts a scroll lasting hundreds of milliseconds, during which every heading it travels past would light up in turn, leaving the entry you clicked as the one thing not highlighted; the list holds your choice until the scroll settles, and hands control straight back if you grab the page mid-flight. offset clears a sticky site header, both for where a click lands and for where the current section begins, since the browser's own fragment jump puts the heading underneath it. It re-measures on resize and once web fonts have loaded, follows a nested scroller when the app shell scrolls an inner element instead of the window, and honours prefers-reduced-motion. The active entry is marked with aria-current="location" rather than colour alone, so it is announced and not merely seen; because clicking has to preventDefault to apply the offset, focus is moved to the heading the way the browser would have, so a keyboard reader lands in the section instead of carrying on down the contents. Modifier and middle clicks are left alone, so opening a section in a new tab still works.
Opens one row of a table and slides the detail for that row in underneath it, spanning the full width of the table. Reach for it wherever a row is a summary of something with more inside it: an orders or invoices table where the row is the order and the detail is the line items, a logs or audit-trail view where the detail is the stack trace or the raw payload, a billing history with a per-charge breakdown, an admin users or accounts table showing a person's roles and permissions, an incidents or alerts list showing the affected services, a deployments or CI runs table showing the failing step, a transactions table with the fee split, an API request log with the response body, a shipments table with the tracking events, a subscriptions table with the plan changes, and any table where the alternative is sending people to a detail page and back. Common asks it answers: "expandable table row react", "react table expandable rows", "table row expand to show details", "shadcn expandable table row", "shadcn table row detail", "shadcn collapsible table row", "shadcn data table expand row", "expandable rows without tanstack table", "tanstack table getCanExpand alternative", "react-table is too much for just expanding a row", "master detail table react", "drill down table row react", "table row accordion", "nested row table react", "sub row react table", "expand row to show more info", "detail row colspan react", "colspan for detail row", "what colspan should the detail row be", "table columns jump when a row expands", "column width changes when expanding row", "last column shrinks when row expands", "detail row breaks table layout", "zebra striping breaks with expanded rows", "nth-child striping counts the detail row", "striped table wrong rows shaded", "animate table row expand", "tr height transition not working", "cannot animate table row collapse", "collapse tr css does not work", "overflow hidden not working on tr", "height 0 on table row ignored", "aria-expanded table row", "accessible expandable table row", "expandable row screen reader", "chevron button has no accessible name", "icon button announces just button", "aria-controls expandable row", "row expander accessibility", "only one row open at a time table", "expand all rows table react", "collapse all rows", "keep expanded row after sorting", "expanded row state lost when table re-orders", "テーブル 行 展開 react", "行を開いて詳細を表示", "アコーディオン テーブル 行", "詳細行 colspan 列数". Official shadcn/ui has the styling for this and not the component, which is unusually direct evidence that the hole is real: its `TableRow` ships `has-aria-expanded:bg-muted/50`, so it already knows an expandable row is a thing people build on top of it — it simply does not build one. Measured across its whole registry today (63 items listed, 62 fetchable, `questionnaire` is indexed and 404s on both style tracks, 223,287 bytes of `files[].content` concatenated): `colSpan`, `colspan`, `aria-controls`, `expandable`, `subrow`, `getCanExpand`, `rowSpan` and `disclosure` are every one of them a zero hit, and the single `aria-expanded` hit is that one styling hook on `TableRow`. Its `table` is 2,468 bytes of styling wrappers. So every expandable row is hand-rolled, and four things go wrong each time — three of them measured in a browser rather than remembered, because the usual advice about each is either wrong or backwards. The first is the detail cell's `colSpan`, and the measurement inverts the instinct. On a 400px three-column table whose detail holds a long stack trace, the correct span of 3 gives column widths of 105.7 / 210.8 / 106.1. A span of 9 — far more columns than exist — gives 105.7 / 210.8 / 106.1: identical to the hundredth of a pixel, because the surplus becomes a phantom zero-width column that takes no space and draws nothing. A span of 2, one short, gives 141.1 / 281.5 / 26.6 — the last column, the one the detail never reaches, collapses from 100px to 26.6px. A span of 1 crushes two columns instead of one. So over-claiming is free and under-claiming is the whole bug, which means the safe fallback when the count is unknown is a large number rather than a small one, and 1 is the worst available answer rather than the cautious one. It is also why this is slippery in practice: the distortion is proportional to the detail's content width, so a short fixture looks perfect and the table bends only once a real stack trace arrives, in the column nobody touched. The second is that the detail row is not a row, and everything that counts rows disagrees. Measured on three rows with two open and both rows sharing one `<tbody>`, `tbody tr:nth-child(even)` does not shift the stripes by one — it lands them on both detail rows and no data row at all, so the table reads as having lost its striping and grown two shaded panels. This component is therefore a `<tbody>`, one per logical row, which is valid HTML and makes `tbody:nth-of-type(even) tr` correct again while shading each pair together. The same correction is what keeps a "3 of 12" counter, a roving tabindex walking rows with Up and Down, and the shift-click range of a row-selection checkbox from counting the panel as data. The third is that a table row cannot be collapsed, so the transition everyone writes is attached to the one element that cannot honour it — and it fails silently, with the row appearing instantly and the CSS sitting there looking correct. Asking each candidate to be zero-tall with `height: 0; overflow: hidden` around two lines of text: a `<tr>` measures 33px, a `<td>` measures 33px, a `<div>` inside the cell measures 0. `height` on a table row is a minimum rather than a size and `overflow` does not apply to `table-row` or `table-cell` at all, so that `<tr>` reports `height: 33px` and `overflow: hidden` together, the second simply having no effect. The detail content is therefore wrapped in a panel inside the cell — the only place a collapse can live — the cell itself carries no padding (padding cannot collapse either), and the panel carries `data-state="open"`, which is the attribute official's own collapsing components key their keyframes off, so `panelClassName="data-[state=open]:animate-accordion-down"` works in any project that has installed `accordion`. It is also where `grid-template-rows: 0fr → 1fr` works, the one transition that interpolates to the content's own height without measuring it in JavaScript. The fourth is the control. `aria-expanded` goes on a `<button>` inside the row and not on the `<tr>`: ARIA permits it on a row, so this is not a validity question — the point is that a row is not operable, takes no focus, answers no key and gives a screen reader no hint that pressing it does anything, so the state would be describing something nobody can press. Official settles it from the other side, since the registry's compiled stylesheet expands `has-aria-expanded:bg-muted/50` to `:has([aria-expanded=true])`, a descendant match — put the attribute on the row and that selector cannot see it, and the open row silently loses the one piece of styling official ships for this pattern. The button is also where the accessible name has to come from, which is the failure that survives every visual review: the control is a chevron with no text, so a table of forty of them is forty announcements of the word "button" with no way to tell which row is which. A `label` naming the row is required rather than optional here, the name becomes "Show details for invoice INV-2048", it flips to "Hide" when open, the chevron is `aria-hidden` so the same fact is not told three times, and `aria-controls` is set only while the detail cell actually exists, because a dangling reference offers a jump that lands nowhere. What you get: `TableRowExpander`, the `<tbody>` holding a row and its panel, which measures the real column count from the live table at press time so the detail row's first render already carries the true span — no first paint at the fallback and no frame in which the columns jump, and it keeps working when the number of columns is variable, as it is under `column-resizer`. `useRowExpansion` holds which rows are open for the whole table, keyed by id so a panel stays with its row when the table is re-sorted rather than with the row's old position, with `multiple: false` for the accordion case and `collapseAll()` for when a filter or the next page replaces the rows. `TableRowExpanderTrigger` is the chevron on its own, for when it has to sit inside a cell you are already rendering, and `trigger="start" | "end" | "none"` chooses whether this adds the chevron's column at all. `TableRowExpanderDetail` is the detail row by itself, `countTableColumns` and `resolveDetailColSpan` are the measuring and the span rule as plain functions for a table that renders its own rows. Everything stays in your `<table>`: the markup, the rows and the data are yours, and this works the same inside official's `<Table>` or a hand-written one. It renders nothing at all while closed, because a row that is present but empty is not cheaper than one that is absent and it leaves a hairline under every row in the table. Distinct from the neighbours: `tree-view` is a nested tree you walk with the keyboard, `nested-checkbox` is the tri-state roll-up of a parent over its children, and `sort-header`, `sticky-table-header` and `column-resizer` all act on the header rather than the body — none of them puts a panel under a row.
A horizontally scrolling strip for a row of tabs that does not fit, which keeps the selected tab in view and puts an arrow at each end while there is more that way. Reach for it wherever a tab row outgrew the space it was designed for: a settings screen whose sections kept being added to, a project or repository page with Overview / Activity / Insights / Settings and six more, a dashboard switching between data sets, a mobile category strip, an admin console's per-entity tabs, a code viewer with one tab per file, a wizard shown as tabs in a narrow dialog, a sidebar somebody can drag narrower, a tab row that was four items when it was designed and is fourteen now, or any tab list whose labels are translated into a language that makes them 40% longer. Common asks it answers: "scrollable tabs react", "shadcn tabs overflow", "tabs too many to fit", "horizontal scrolling tabs", "tabs with scroll arrows", "scroll active tab into view", "keep selected tab visible", "tabs scroll to selected", "MUI Tabs variant scrollable equivalent shadcn", "scrollButtons auto", "antd tabs overflow", "chrome-style tab strip", "tab bar scrolls on mobile", "tabs wrap to second line", "stop tabs from wrapping", "tabs squeeze and truncate", "radix tabs overflow scroll", "TabsList scrollable", "scrollIntoView scrolls the whole page", "scrollIntoView block nearest still scrolls page", "prevent scrollIntoView from scrolling parent", "scroll a div without scrolling the page", "scrollLeft negative in RTL", "rtl horizontal scroll react", "hide scrollbar but keep scrolling", "scrollbar-width none tailwind", "show arrows only when scrollable", "detect if a div is scrollable", "ResizeObserver loop with overflow buttons", "arrow buttons change the width they measured", "roving tabindex arrow keys conflict with scroll", "tablist keyboard navigation", "タブ スクロール react", "タブが2行になる", "タブが多すぎて入らない", "選択中のタブを見える位置へ", "横スクロールするタブ", "タブに矢印ボタン", "scrollIntoView でページごと動く", "親要素だけスクロールさせたい", "RTL で scrollLeft が負になる", "スクロールバーを隠す", "矢印は端で消す", "tablist 矢印キー 競合". What official shadcn/ui ships has no overflow story at all, and that is an omission rather than a design decision. Its `tabs` is a 3,772-byte Radix wrapper whose only dependencies are `cn` and `radix-ui`: `TabsList` is `inline-flex w-fit`, so it is sized by its contents and simply becomes wider than its parent, and `TabsTrigger` carries `flex-1` with `whitespace-nowrap`, so the triggers first squeeze each other toward their text and then run past the edge, where whatever ancestor happens to clip decides what you can still see. Nothing in it measures anything. Across the whole official registry today — 65 items listed, 62 fetchable, `code-block`, `questionnaire` and `trace` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `scrollIntoView`, `scrollWidth`, `clientWidth`, `offsetWidth`, `scrollLeft`, `scrollTo`, `ResizeObserver` and `tablist` are every one of them a zero hit, and the single match for `aria-selected` is a Tailwind variant in `calendar`. The two neighbours that do scroll are a different job: `carousel` hands its scrolling to `embla-carousel-react` and moves between slides, with no selected item to follow, and `message-scroller` delegates to `@shadcn/react/message-scroller` and chases the newest message at the bottom. Neither has the thing a tab strip needs, which is to follow a selection somebody else owns. The obvious implementation is `scrollIntoView` on the selected tab, and it is wrong in a way that is invisible while you build it. `scrollIntoView` scrolls every scrollable ancestor to reveal the element, the document included, so on a short page with the strip near the top it looks perfect — and on a real page, with the tabs partway down a long settings screen or inside a dialog, selecting a tab scrolls the page vertically and throws the content the reader was looking at off the screen. `block: "nearest"` shortens the trip without stopping it: nearest is still a scroll whenever the element is not already fully visible, and it still applies to each ancestor in turn. Measured in Chrome rather than argued about: on a strip 1,200px down a tall page, `scrollIntoView({ block: "nearest", inline: "nearest" })` on the fifth tab took the document from `scrollY` 0 to 1,201 and parked the tab hard against the edge at `scrollLeft` 300 with no gutter, while the same tab through this component moved the strip to 332 and left `scrollY` at 0 — predicted offset 332 against an actual 332, with the 32px gutter left past the tab exactly as asked. The only way to move one box and nothing else is to work out the offset and set it on that box, which is what this does — `nextTabScroll` is the arithmetic as a pure function and the strip is moved with `scrollTo` on itself, so nothing above it in the tree is touched. The arrows are painted over the ends of the strip rather than laid out beside it, and that is load-bearing. Arrows that take part in layout make the window narrower exactly when they appear, and the window's width is the input that decided they were needed, so a strip whose tabs fit the full width but not the width-minus-two-buttons has no stable answer — the version everybody writes first either oscillates or settles on whichever state it started in. Overlaid, the window is the same width whether they show or not, so the measurement cannot be disturbed by its own result: measured on this pair, an absolutely positioned button left the scrollport at 300px while the same button as a flex sibling changed it. They are `aria-hidden` and not tab stops, because the scrolling they do is already reachable: moving the selection scrolls the strip, and a screen reader offered "scroll tabs right" has no way to aim it or to know when to stop. They stand in for the scrollbar, which is hidden — a horizontal scrollbar under a row of tabs is a second control for the same job that also changes the strip's height on the platforms that reserve room for it. It binds no keys, and that is the one thing that makes a scrolling tab strip different from a scrolling row of anything else: a `tablist` has already spent the arrow keys. Left and Right move the selection, and binding them here as well would collide with the keys that do, resolving the wrong way round — the person pressing Right wants the next tab, not the same tab with the strip slid underneath it. So this wraps a tab strip instead of being one. No role is imposed on the children and no tab state is owned here: Radix's `TabsList` stays the `tablist`, its triggers stay the tabs, and keyboard users move the selection while the selection brings itself into view, which is the same motion driven by the input that was already there. Which is also why it takes no prop saying which tab is current. The selected tab is found with a selector covering both conventions — `aria-selected="true"` from the ARIA practices and a hand-rolled strip, `data-state="active"` from Radix and therefore from official's `TabsTrigger` — and a `MutationObserver` watching just those two attributes re-reads it when either changes anywhere inside. Nothing has to be lifted or wired, and it behaves the same under Radix, under your own strip, and under a router that derives the tab from the URL. It is re-scrolled only when the selected tab actually changes, never on any other mutation, so a badge count ticking over or a label being translated does not drag back a user who scrolled by hand to look at something; a resize is the single exception, because it can carry the selected tab out of the window and the tab being visible is the invariant worth re-asserting. Which end has more is `scroll-shadow`'s `useScrollEdges`, imported rather than written again — that hook's own documentation names arrow buttons on a tab strip as the reason it is exported. It brings the two things a hand-rolled version gets wrong: that an RTL box reports `scrollLeft === 0` while half its tabs are hidden off to the left, because the CSSOM puts 0 at the initial position and runs negative going left — a fresh RTL strip here reported `scrollLeft` 0 with 420px of tabs hidden, and the offset that brings the same tab into view is written as -332; and that a `scroll` listener alone leaves the arrows missing on arrival, which is the moment they are needed, since a strip that arrives from a fetch already too wide — or inside a panel that was `display: none` until its accordion opened — has overflowed without anybody scrolling. Three smaller things are written down in the source because each is a way to be a pixel or two wrong in a visible place: the window is the scrollport rather than the border box, so a strip with a border does not park its tab under an arrow; a gutter of 32 is kept between the tab and the edge, because a tab flush against the edge cannot be told from one that continues past it and is half-covered by the chevron painted over it; and a target within half a pixel of where the strip already is is not asked for, since an animated scroll is itself a stream of scroll events and a smooth scroll to where you already are never finishes. The first positioning is instant and the rest animate, so a strip whose twelfth tab is selected on arrival does not sweep past the first eleven on load, and `prefers-reduced-motion` turns the animation off on both the selection path and the arrows. What you get: `TabScroller`, wrapping whatever tab strip you already have, with `align` (`"nearest"` moves as little as possible and leaves a tab that is already comfortably in view exactly where it is; `"center"` always parks it in the middle), `gutter`, `arrows`, `scrollStep` — honoured at `0`, which is the value a caller passes to pin the arrows — `activeSelector` if your strip marks the current tab some third way, `viewportRef` for calling `scrollTo` yourself, and `viewportClassName` for the scrolling element's padding, gap or scrollbar. `nextTabScroll`, `readTabVisibility` and `scrollLeftForOffset` are exported separately: the arithmetic, the measurement and the RTL sign rule, each assertable on its own, which is how the clamps, the gutter boundaries, the tab wider than the window and both writing directions are tested as numbers rather than reasoned about. One file, two lucide icons, no `radix-ui`. Honest limits: the server cannot measure, so an SSR'd strip renders scrolled to its start with no arrows and a selected tab that is off-window is correct one frame after hydration rather than in the HTML; and the strip clips, so a popover opened from a tab should be portalled out as usual. Distinct from its neighbours here: `scroll-shadow` fades an edge to say there is more past it and has no selected item to follow, and this composes with it rather than repeating it; `toolbar-overflow` folds what does not fit into a menu at the end, which is the right answer for actions and the wrong one for tabs, since a tab hidden in a menu cannot show that it is the current one; `breadcrumb-overflow` folds the middle of a path; `overflow-tooltip` reveals one string that CSS has already clipped, which is the answer for a long label rather than for a long row; `segmented-control` is a `radiogroup` picking a value among a few short options that are meant to fit; and `swipe-actions` and `pull-to-refresh` are touch gestures rather than a measured row.
A tab-title badge: it puts "(3)" in front of the page title while the reader is in another tab, takes it back off the moment they return, and never steals the title from the router. Reach for it wherever something arrives while nobody is looking: unread messages in a chat, support inbox or team channel, a notification or alert count on a dashboard, an export, import, render or report that finishes minutes after it was kicked off, a CI run, deploy, migration or test suite a developer is waiting on, a background sync or upload queue, approvals or review requests piling up, a live auction or bid about to close, a pomodoro or cooking timer ending, the other player's turn, and a call or meeting waiting to be joined. Common asks it answers: "react document title unread count", "set document.title in react", "useDocumentTitle hook", "tab title notification badge", "show unread count in browser tab", "put (1) in the tab title react", "unread badge in page title", "blinking tab title react", "flash tab title on new message", "clear the tab badge when the user comes back", "detect when the user returns to the tab react", "visibilitychange react hook", "document.title resets on route change", "title flickers after navigation next.js", "document.title not updating in next app router", "restore the original document title on unmount", "document.title restored to the wrong page title", "two components fighting over document.title", "react-helmet just for a counter", "title badge shadcn", "notification count in tab title". shadcn/ui ships nothing that touches the tab: measured against the live catalogue on 2026-10-27 (65 items indexed, the 62 fetchable ones concatenated to 223,287 bytes of source), document.title, favicon, visibilitychange, Notification, rel="icon" and unread are all zero hits. It is the quiet counterpart to pulld install-prompt and notification-permission — it tells the reader something is waiting without asking the browser for permission to interrupt them, and unlike a toast it is still there when they come back in an hour. Written from the platform up, because the obvious version — read document.title on mount, prefix the count, put the remembered string back on unmount — is wrong in four separate ways that all look fine on one static page, and all four were measured in a real browser rather than remembered. One: the remembered title goes stale. In a single-page app the router writes a new title on every navigation, so the value captured at mount belongs to whatever page was open then, and restoring it puts a dead page's title over the live one; this hook adopts every title it did not write as the new base, so the badge always lands on the page the reader is actually on. Two: writing the same string still counts as a change — assigning document.title a value it already holds fires a mutation record all the same, so "re-apply the badge whenever the title changes" answers its own notification forever; in the probe it looped until a counter cut it off, and here one equality check ends it. Three: the change does not arrive where a first guess puts it. A plain assignment is a childList record on <title>, not characterData, and a title library that swaps the whole <title> element produces a childList record on <head> and leaves an observer bound to a node that is no longer in the document; one observer on <head> with subtree catches both, and neither narrower choice catches the other. Four: there is exactly one document.title, so a second instance is not a second badge but a second writer — the first to mount owns the title and the rest report isOwner false and hold still, the same shape pulld fullscreen-button and read-aloud use for the other page-wide APIs. The API is the count and what to do about it: count (0 and anything below 1 shows nothing), label for a word instead of a number ("New", "2 failed"), max to write anything larger as "99+", template to choose the join ("({badge}) {title}" by default, "{badge} • {title}" and "{title} — {badge} new" both work), showWhileSeen to keep it up in front of a reader who is already here, blinkMs to alternate with the page's own title, and onSeen to be told when they come back so your own counter can go to zero — the badge is a view of your state, never a copy of it. seenRule picks what "looking" means: the default follows Page Visibility, and "focus" additionally requires the window to be in front, which matters because a tab stays visible while its whole window sits behind the editor, so under the default rule a message that arrives while the reader is in another application clears itself unseen. useTabTitleBadge is the hook; <TabTitleBadge count={unread} /> is the same thing declaratively and renders nothing at all, because what it writes lives in the tab rather than on the page. Server-safe, no dependencies, not even an icon.
A multi-value text field: what gets typed becomes a removable chip, and the value the form sees is a plain string[]. Enter or a comma commits the draft, Backspace on an empty field takes back the last chip, the x on a chip removes it, and pasting a comma- or newline-separated list adds the whole list at once. Reach for it wherever a field takes several short values a person makes up as they go: the tags, labels, topics or keywords row of a create or edit form, the To/Cc/Bcc recipients of a compose box, an invite-by-email box that takes a pasted column out of a spreadsheet, a filter bar accepting several terms at once, skills or interests on a profile, SEO keywords or meta tags in a CMS, an allowlist of domains, IP ranges, origins or redirect URIs in a settings panel, environment-variable keys, and the categories on a product or article. Common asks it answers: "tag input", "tags input react", "chips input", "token input", "multi value input", "add tags with Enter", "comma separated tags field", "type and press enter to add", "email recipient input", "invite emails input", "bcc chips field", "keyword filter input", "allowlist input", "react-tag-input alternative", "react-tagsinput alternative", "tagify alternative", "react-select creatable alternative", "shadcn tags field", "shadcn chips input". shadcn/ui has no tag, chip or token input anywhere in its sixty-odd components, so this is one an agent writes inline — and the inline version has three bugs that all look fine on the screen where you test them. The first is the form: a keydown handler that adds a tag on Enter without calling preventDefault leaves the Enter to do its normal job, so pressing it inside a <form> submits a half-filled form instead of adding a tag (and the comma, unstopped, is also typed into the field). Both keys are prevented here, and your own onKeyDown still runs first and can preventDefault to take the key back. The second is paste, which is where multi-value fields actually get used and where the obvious loop is wrong: calling addTag once per pasted value reads the tag list from a render that has not happened yet, so every iteration after the first sees a stale list — the cap and the duplicate check are computed against it, and depending on how state is set, most of the pasted values are silently dropped. The add is written as a pure function over a working list instead, threaded through every candidate and committed once, so ten pasted emails arrive as ten chips with one onChange and one announcement. The third is that a chip appearing or vanishing is a change nobody using a screen reader hears — the input's own value did not change, and neither did focus — so each add and remove is spoken through a polite live region ("Added design", "Removed design", "Added 5 tags" for a batch), and every chip's remove button carries its own label naming the tag it drops rather than a row of identical "Remove" buttons. Duplicates are matched case-insensitively, so React and react are one tag and not two, and allowDuplicates turns that off. The remove buttons are deliberately outside the tab order: a field holding twenty tags would otherwise be twenty-one tab stops on the way to the next input, so keyboard removal is Backspace from the field and the x is there for the pointer. Controlled with value/onChange or uncontrolled with defaultValue; max caps the count, validate rejects a candidate before it becomes a chip (an email regex, a lowercase-slug rule), the ref forwards to the real input so you can focus it, and clicking anywhere in the box focuses it too. The string[] drops straight into react-hook-form's Controller or any controlled state in React or Next.js, and remaining props land on the inner input, so placeholder, name, id, aria-* and data-* all work. Chips use the secondary token and the box uses input, ring and muted-foreground, so it follows light and dark with no extra styling; className styles the box and inputClassName the field inside it. One file, one lucide icon, no tag library. Distinct from pulld multi-select, which picks from a fixed list of options you supply: reach for that one when the set of valid values is known, and for this one when the person is inventing them.
A document that has to be scrolled to its end before the control under it will do anything — the "scroll to accept" box on a terms page, and the one piece of a consent flow that is almost always hand-rolled and almost always broken. Reach for it wherever somebody has to be shown a document before they can continue: terms of service and an end-user licence, a privacy policy, cookie and data-processing consent, a SaaS onboarding step, financial and insurance disclosures, a mortgage or loan agreement, a medical or clinical consent form, a research study participation sheet, age verification, an employee handbook or code-of-conduct acknowledgement, an NDA, a liability waiver, a safety briefing, a tenancy agreement, a terms-change re-consent prompt, and release notes or a changelog nobody is allowed to have missed. Common asks it answers: "scroll to accept", "scroll to bottom to enable button", "disable agree button until scrolled to bottom", "must scroll terms before accepting", "read to the end before continuing", "terms and conditions scroll react", "shadcn terms scroll", "consent gate react", "scroll to agree checkbox", "scrolled to bottom detection react", "detect scroll end react", "scroll to bottom not working", "scrollTop clientHeight scrollHeight never equal", "scroll end detection off by one pixel", "scrolled to bottom fails at browser zoom", "agree button never enables", "short terms no scrollbar button stays disabled", "terms modal scroll to bottom", "EULA scroll component", "privacy policy accept component", "useOnScreen hook", "has element been seen react", "IntersectionObserver seen once", "useInView latch", "規約 最後までスクロール 同意", "同意ボタン スクロール 有効化", "スクロール 最下部 判定 react", "利用規約 同意 コンポーネント". **The arithmetic everybody writes for this is the wrong tool, and its failure is the one that matters.** `scrollTop + clientHeight >= scrollHeight` is the obvious test and it is three separate bugs. `scrollTop` is fractional while the other two are rounded integers, so at any browser zoom other than 100% the sum lands a fraction of a pixel short of the total and the agree button never enables — on a page where there is nothing at all the reader can do about it. A document shorter than its box never scrolls, so the same test has to be special-cased or a four-line policy locks the form forever. And a box that has not been laid out — in a dialog that has not opened, a tab that is not selected, anything `display: none` — reports zeroes for all three, which *satisfies* the test: the gate opens for terms that have never been on screen. So the end of the document is marked with an element and watched with an IntersectionObserver instead, and all three dissolve rather than being fixed. Intersection is computed in layout units with no rounding, a short document's end marker is simply already visible, and an element in an unrendered subtree has no boxes and cannot intersect anything. Content that grows after first paint — an image decoding, a lazily loaded clause, a web font swapping in — is handled by the browser for free, where the arithmetic version needs a scroll listener, a resize listener, a ResizeObserver and a wait on `document.fonts.ready` just to notice that the end moved. The observer has no `root`, which is the other half of the design: with the scrolling box as the root, a caller who forgets to cap its height gets a gate that opens instantly, because the end marker sits inside that box's own bounds whether or not any of it is on screen. Measured against the viewport, and clipped by every scroll container in between, one observer covers both layouts honestly — the end of a capped box counts when it is scrolled to *and* the box is on screen, and an uncapped document counts when the page reaches its last line. That is also stricter than any `scrollTop` sum, since the reader cannot satisfy a box whose bottom edge is below the fold. Once seen it stays seen. A reader who reaches the end and scrolls back up to re-read a clause has not undone anything, and a gate that closes behind them is the second most common complaint about this pattern after the one that never opens; `resetKey` is what puts the gate back, for a new version of the document rather than for a scroll position. The observer is thrown away the moment it latches, so a long page of these costs nothing once the reader has passed them. **It does not claim anybody read anything.** Displaying the last line is not reading it, this component cannot tell the difference, and the labels say only what is true ("You've reached the end", not "You have read the terms"). It records nothing, transmits nothing and proves nothing: it is an affordance that stops a reader clicking past a document they have not seen, not evidence of consent. There are readers for whom the end comes into view without any reading at all — a screen reader moving through the content, Cmd+End, a find-in-page hit near the bottom — and that is the right bias, because every alternative is a gate that traps somebody. The gated control stays focusable throughout, which is why `action` hands you `reached` and a `descriptionId` instead of rendering a button for you: a `disabled` button is removed from the tab order, so a screen reader user finds nothing where the submit should be and gets no explanation of what is missing, while `aria-disabled` with the click ignored leaves it discoverable and described by the status line. That status line is a single polite live region holding one of two strings, so reaching the end is announced once when it happens rather than as a percentage read out over the document the reader is trying to read; it also names the scrolling box through `aria-describedby`, so a reader who tabs into the box hears where they are in it. `scrollToEnd` is handed over too but deliberately not rendered — whether skipping to the end is acceptable is your call, not this component's. Built on pulld's `scroll-shadow`, so the box fades at the edge while there is more below, takes a tab stop only while it actually overflows, and carries its name and `region` role on the element that scrolls rather than on a wrapper nobody can reach. `useSeenOnce` is exported on its own: "did this element ever come into view", latched, which is the useful half of an IntersectionObserver and almost never what the API gives you, since entries arrive on the way out as well as the way in. Theme tokens throughout with no hardcoded colours, one lucide icon, and nothing else added. Official shadcn/ui has nothing here and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings scrollHeight, scrollTop, clientHeight, IntersectionObserver, isIntersecting, consent, agree, accept and scrollIntoView are every one of them a zero hit. Official never measures a scroll position at all; its `scroll-area` hands that to Radix, and its `progress` is a bar you drive with a value you already have. Within pulld it sits beside scroll-progress and scroll-shadow, which report on a scroll without judging it, and beside unsaved-changes-guard and type-to-confirm, the other two components that stand between a reader and an action they cannot take back.
The one-button light/dark switch you drop in a navbar, header or settings row — click it and the whole app flips theme, and the choice survives a reload. Reach for it on any site that has a dark mode at all: the header of a marketing or docs site, an app shell or dashboard sidebar, a settings or appearance page, an admin panel, a developer tool or playground, a blog, and the top-right corner of more or less every React starter. Common asks it answers: "dark mode toggle", "theme toggle button", "light dark switcher", "dark mode toggle react", "theme switcher react", "toggle dark mode tailwind", "tailwind dark class toggle", "sun moon toggle button", "dark mode without next-themes", "next-themes alternative", "theme toggle shadcn", "shadcn dark mode toggle component", "shadcn mode toggle", "nextjs dark mode toggle", "vite react dark mode", "react router dark mode", "astro dark mode toggle", "remember user theme localStorage", "respect prefers-color-scheme", "system theme default dark mode", "dark mode flash on page load", "FOUC dark mode nextjs", "hydration mismatch dark mode", "window is not defined dark mode", "toggle dark class on html element", "dark mode toggle without provider", "dark mode toggle accessibility aria-pressed", "ダークモード 切り替え react", "テーマ切り替えボタン", "ダークモード 初回表示 ちらつき", "システム設定に追従 ダークモード". shadcn/ui has no installable toggle, and the measurement is unambiguous: across all sixty-three registry entries (sixty-two fetchable today) the only component that touches theming at all is sonner, which calls next-themes' useTheme to decide what colour to paint a toast — it reads the theme, it does not switch it. The official dark-mode guide is a guide: it hands you a next-themes provider to wire up and a dropdown to assemble yourself, so the button itself is written by hand every time. This is that button — one file, no provider, no context, no next-themes, no extra package. It toggles the `dark` class on the html element, which is exactly what Tailwind's class dark mode and the shadcn tokens already read, so it works with the theme you have rather than introducing another one. On first load it reads the saved choice from localStorage and falls back to the OS `prefers-color-scheme`, so a first-time visitor gets their system theme and a returning one gets their own; every later click writes the choice back. That first read happens in an effect rather than during render, because `window` does not exist on the server and an inline branch would either crash SSR or hydrate to different markup than it sent — which is the `window is not defined` / hydration-mismatch pair that the hand-written version hits first. It also means the theme is applied just after first paint, so add the usual one-line script in your document head if you need to kill the flash on a static page. The Sun/Moon swap is done with the `dark:` variant rather than JS state, so the icon matches the document even if something else on the page changes the theme. It is a real button that forwards every button prop (className, id, onClick, disabled), carries an aria-label and an aria-pressed that reflects the current mode, hides both icons from screen readers, and has a focus-visible ring; styling uses shadcn tokens (accent, muted-foreground, ring, border) so it matches your other icon buttons. Within pulld it sits with the other things that live in a header: kbd and keyboard-shortcuts, command-palette, network-status and announcement-bar. lucide-react is the only dependency.
Auto-updating relative timestamp — "3 minutes ago", "just now", "in 2 days" — that re-renders on its own timer so the label stays true without a reload. Reach for it wherever a full date would be noise and recency is the point: comment, post, review and chat message times, a notification or activity feed, "last seen", "last updated", "last synced" and "last login" labels, commit, build and deploy history, created/modified columns in an admin table, an audit or event log, support-ticket and order-status updates, a file list, a session or API-key list, and cache or backup freshness. Common asks it answers: "react time ago component", "relative time react", "time ago react", "x minutes ago react", "react-timeago alternative", "timeago.js alternative", "dayjs fromNow without dayjs", "moment fromNow replacement", "date-fns formatDistanceToNow component", "Intl.RelativeTimeFormat react hook", "relative timestamp that updates live", "auto refreshing timestamp react", "shadcn time ago", "shadcn relative time", "localized relative time react", "yesterday instead of 1 day ago", "semantic time element react", "relative time hydration mismatch", "timestamp tooltip with full date". Official shadcn/ui has nothing for this and nothing to build it out of: RelativeTimeFormat, timeAgo, fromNow, formatDistance, dateTime, the string "just now", the <time> element and even "Intl." are each zero hits across all 62 of its fetchable items (measured 2026-09-25; the 30 hits for "relative" are all Tailwind's positioning class). Distinct from the other time pieces in this registry: countdown runs toward a deadline and ticks every second, save-status reports the state of one save rather than the age of a thing, date-input and time-input are for entering a value, month-picker chooses a month, and calendar-heatmap draws a year of days — this one only says how long ago something happened, and keeps saying it correctly as the page stays open. The wording comes from the platform's own Intl.RelativeTimeFormat, so it localizes for free through the locale prop and reads correctly for past and future alike; numeric="auto" gives "yesterday"/"tomorrow" instead of "1 day ago", and format="short" or "narrow" gives "3 min. ago"/"3m ago". Anything newer than justNowThreshold seconds (default 45) shows justNowLabel. The tick rate follows the age of the thing rather than the clock — every 15s while under a minute old, once a minute under an hour, then hourly — which is the difference between a feed of fifty timestamps and a feed of fifty one-second intervals; pin it with updateInterval when you want one rate. It renders a semantic <time> with a machine-readable dateTime and a title carrying the full localized date, so the exact moment is still reachable on hover and by anything reading the markup. The clock is only ever read after mount, so a server render and the browser's first paint cannot disagree about the age of something that changes every second. An unparseable date renders nothing rather than the words "Invalid Date". Accepts a Date, an ISO string or epoch milliseconds, theme-aware through the text-muted-foreground token, and depends on nothing but your cn util — no date library, no extra packages.
A time field you type into, one segment at a time, that hands back a plain 24-hour clock string — "09:30", or "09:30:15" with seconds — and never a date and never a time zone. Reach for it wherever a form asks *when on the clock*: meeting, appointment and booking start and end times; opening hours and store hours; shift rosters, on-call rotations and availability editors; class and slot times; reminder, alarm and snooze times; quiet hours and do-not-disturb windows; the time half of a deadline or cut-off; the hour a scheduled report, digest email, backup or CI job runs; a maintenance window; delivery and pickup windows; check-in and check-out times; and the time part of a cron expression assembled in human terms. Common asks it answers: "time input", "time picker", "time field", "hh:mm input", "24 hour time input", "12 hour time picker", "AM PM input", "time picker without a library", "keyboard time entry", "shadcn time picker", "shadcn time input", "input type=time replacement", "styled native time input", "opening hours input", "quiet hours picker", "start and end time picker", "meeting time input", "react-time-picker alternative", "MUI TimePicker equivalent", "antd TimePicker equivalent", "rc-time-picker alternative". shadcn/ui ships nothing that touches the clock: fetching the source of all 63 items in its registry and grepping them turns up no type="time", no hourCycle, no hour12 and no AM/PM anywhere. Its calendar is a react-day-picker wrapper that answers which day, input is a bare text box you would still have to parse, and input-otp is a fixed-length code with no time meaning. It is also the "when" half of a pair: duration-input answers *how long* — 90m, 1h30m, and 1:30 meaning a minute and a half of elapsed time — while this one answers what the clock reads, where 1:30 is half past one. Alongside date-input (the day), month-picker (the month) and timezone-select (which zone a time is meant in), this is the one that types the time. The work is in the parts that are easy to get wrong. "Twelve-hour" is really two different clocks and the component implements all four: en-US writes midnight 12 AM and counts 12, 1, 2 (h12) while ja-JP writes it 午前0時 and counts 0, 1, 2 (h11), and en-GB and de-DE are on 00–23 (h23) with h24 counting to 24 — the cycle comes from Intl rather than from a hardcoded guess, so nobody is shown an hour their locale does not write. The displayed 12 falls to hour 0 before the PM half is added, which is the off-by-twelve that quietly turns a noon deadline into a midnight one. Segment order, the separators and the AM/PM wording all come from the locale too — ko-KR puts the day period before the hour, ja-JP writes 午前/午後 — while the digits themselves are rendered as ASCII, so an ar-EG reader is not shown Arabic-Indic numerals that the number keys cannot reproduce. Auto-advance is decided by range rather than by counting to two: 5 jumps straight to the minute on a 24-hour clock because no hour starts with 5, 1 waits for a possible 10–19, and a lone 0 is already midnight there while on a twelve-hour clock it waits for the digit that makes it 01–09. A pair that cannot exist starts a new number instead of dropping the keystroke. Arrow keys step a segment and wrap, and the hour stays in its half of the day the way the native control does — 11 AM steps to 12 AM, not to noon. minuteStep rounds an off-step minute toward the arrow, so 07 on a 15-minute step gives 15 going up and 00 going down instead of 22 and 52. min and max are flagged with aria-invalid without ever blocking typing, and a max earlier than min is read as a range that wraps past midnight — the HTML rule for time inputs — which is what lets quiet hours of 22:00–06:00 or a night shift be one field. Every segment is a spinbutton with its own label and range, the day period is announced by name rather than as a bare number, and Backspace, Home, End and the left/right arrows move around the field. Pasting accepts "14:30", "2:30 PM" and the locale's own wording. No Date object is ever constructed and no zone is ever applied, so the value is a wall-clock time that survives being stored and read back anywhere. Works controlled or uncontrolled, forwards a ref to the first segment so a shortcut can focus it, and mirrors the value into a hidden input for native form submit. Theme-aware via shadcn tokens; no dependencies — no date library, no time picker package.
A vertical timeline: a list of events drawn as dots on a connecting line, each with an optional timestamp, title, description and icon. Reach for it wherever the content itself is "what happened, and in what order": an activity feed on a record, profile or dashboard; an audit, security or admin history log; a changelog or release-notes page; order, shipment and delivery tracking; a deploy, build or CI/CD run log; incident updates on a status page; a support ticket's history; approval and review trails; the event stream on an order, invoice or subscription; notification history; a product roadmap; and the experience or education list on a resume or "about" page. Common asks it answers: "timeline component", "vertical timeline", "activity feed", "activity timeline", "history log component", "audit log UI", "changelog timeline", "release notes timeline", "order tracking timeline", "shipment tracking UI", "deploy history list", "event stream component", "status timeline", "shadcn timeline", "shadcn activity feed", "react vertical timeline alternative", "react-chrono alternative", "MUI Timeline equivalent", "antd Timeline equivalent", "resume timeline". shadcn/ui ships no timeline: fetching the source of all 63 items in its registry and grepping them turns up no timeline, no <time> element and no connector line anywhere. The nearest things it has are marker — one annotation row of an icon beside muted text, with a variant that rules a line across it, the "New messages" divider in a chat — and item, a kit for laying out a single row; neither stacks events on a shared line nor carries a time. It is also not step-indicator, the other dots-on-a-line component here: a step indicator counts position through a sequence that is known in advance and still to be finished, while a timeline is the record of what already happened and has no current step. Pass an items array (title, optional time, description, icon and a colour accent). The timestamp renders as a semantic <time> element with a machine-readable dateTime, so it is legible to a reader and to a crawler; the connecting line and the decorative dots are aria-hidden, so a screen reader hears the events rather than the ornament; and each marker takes a per-item colour (muted, primary, success, warning, destructive), so status like succeeded, failed or pending can be flagged without reaching for a second component. Pass an icon to render an icon badge instead of a plain dot. It is a pure display component with no state, so it renders inside a server component with no "use client" and costs nothing on the client. Theme-aware via shadcn tokens with dark mode; no dependencies (bring your own icons).
A time zone picker: every IANA zone the browser knows, grouped by region and labelled with the UTC offset it is actually on — "New York (UTC-04:00)", "Kolkata (UTC+05:30)", "Chatham (UTC+12:45)". Reach for it wherever an app has to store which zone a time is meant in: the "Your time zone" row in profile, account or notification settings; a workspace or organisation default for a distributed team; scheduling and booking flows where the two parties are in different places; meeting, event and webinar creation; availability and working-hours editors; quiet hours and do-not-disturb windows; shift rosters and on-call rotations; the zone a cron job, scheduled report, digest email or CI job is read in; billing and invoice cycle boundaries; the "display times in" control on a dashboard, log viewer or analytics report; and any form that already collects a date and needs to know which midnight it meant. Common asks it answers: "timezone picker", "timezone select", "time zone dropdown", "timezone selector react", "IANA timezone select", "select timezone component", "list of timezones react", "timezone select with UTC offset", "shadcn timezone picker", "shadcn time zone select", "react-timezone-select alternative", "timezone combobox", "choose timezone for scheduling", "user timezone setting component". Official shadcn/ui has nothing for this and no combination of its parts gets there: select, native-select and combobox are empty controls that know no zones, and calendar and date-picker choose a day and never say which zone that day is counted in. The component here is the data and the labelling, not the control. The zone list comes from `Intl.supportedValuesOf("timeZone")`, so it is the runtime's own tzdata — 418 zones on current browsers — and it ages with the browser instead of with a package you have to remember to bump. UTC is added explicitly, because that call omits it on several runtimes and it is the one zone a scheduling or logging UI is most likely to want. Offsets are read through `Intl` at a reference date rather than computed by subtracting two Dates, which is what keeps the zones that are not on a whole hour honest: India at +05:30, Chatham at +12:45, Marquesas at -09:30. And because an offset is a property of the date and not of the zone — Berlin is +01:00 in January and +02:00 in July — `referenceDate` moves the whole list to the instant being scheduled, so a picker for a meeting in three months does not label its options with today's daylight saving. It renders a native `<select>`, so keyboard support, the mobile wheel and form submission come from the platform rather than from a listbox reimplementation, which for a list this long is the difference between usable and not. That choice decides the labels too: a native select's only search is type-ahead, and labelling the options "(UTC-04:00) New York" the way most pickers do points all 418 entries at "(" and throws the feature away — so the city comes first, the offset trails in parentheses, and each region group is sorted alphabetically, in the same order type-ahead walks. Three failure modes it settles that only show up in production. The option list is built after mount, never during the server render, because the zone list, the tzdata behind the offsets and "now" are all properties of the machine — rendering them on both sides is a hydration mismatch on a page that was otherwise deterministic; before mount the field renders the current value under its raw id, so it is still present and submittable. The select is controlled internally even when the caller leaves it uncontrolled, because replacing the children of an uncontrolled select drops the DOM's selection and the field would silently reset on hydration. And a value the list does not contain is added back as its own option — the runtime offers canonical ids only, so a legacy form saved years ago is absent (current runtimes still answer to "US/Pacific" but do not list it), as is any zone picked before a narrowed list was narrowed instead of letting the select fall to its first entry and read as though the user had picked Abidjan. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), always emitting the IANA id and never a display label; `placeholder` adds an empty first option that `required` still rejects; `timeZones` narrows the list to the places a product actually operates in; and `getLocalTimeZone()` is exported for seeding the field with the visitor's own zone from an effect rather than from a render the server also runs. Labelled for assistive technology either way: it falls back to an accessible name only when no `aria-label`, `aria-labelledby` or `id` says one already exists, so a visible `<Label htmlFor>` is never overridden. Styled entirely with shadcn tokens (input, ring, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no timezone package, no icon package, one file.
A complete toast / notification system in one file: call `toast()` — or `toast.success` / `.error` / `.info` / `.warning` / `.loading` / `.promise` — from anywhere in your app, and render a single `<Toaster />` at the root. No provider, no context, nothing to wire up. Use it for save and delete confirmations, form submission results, copy-to-clipboard feedback, async job status, optimistic updates that may fail, undo prompts, rate-limit and validation errors — any "it worked" or "it failed" message that should not interrupt what the user is doing. Common asks it answers: "toast notification react", "toast component shadcn", "snackbar component", "notification popup", "flash message", "alert toast", "undo toast with action button", "promise toast for async requests", "loading toast that turns into success", "toast without a provider", "toast from outside a component", "sonner alternative", "react-hot-toast alternative", "react-toastify alternative", "notification system react", "show a message after form submit". `toast.promise(request, { loading, success, error })` moves one toast through all three states in place, so an async call needs a single line instead of a chain of manual dismissals — `success` and `error` also accept a function, so the final message can quote the resolved value or the thrown error. Every toast takes a `description`, an `action` button (the undo affordance), a `duration` where `Infinity` pins it until dismissed, and an `id` you can reuse to update a toast already on screen. `<Toaster />` takes any of six positions. The queue lives outside React through useSyncExternalStore, so a toast can be fired from an event handler, a fetch or axios interceptor, a route guard, or a plain module — the places a hook-based API cannot reach, and the usual reason a toast library ends up wrapped in a context that has to be threaded everywhere. The details a rushed implementation drops: auto-dismiss pauses while the pointer is over the stack or focus is inside it, so a toast cannot vanish mid-sentence or while a keyboard user is reaching for its action button, and it stays paused across a promise's loading → success swap. Errors announce assertive and everything else polite, so a failure is not queued behind three success messages. Swipe-to-dismiss on touch, and enter / exit animations that respect prefers-reduced-motion. Official shadcn/ui offers two, and both hand you a package to carry: its toast is built on @radix-ui/react-toast and only works once you have mounted a ToastProvider and a ToastViewport and threaded its useToast hook to every caller, and its sonner is a wrapper around the sonner package plus next-themes. This is one file you own and can edit, styled with your own theme tokens, with no provider to mount and no context to thread, and lucide-react — already present in a shadcn project — as its only package import.
A row of controls that folds whatever will not fit into a menu at its end — the pattern usually called priority+. Reach for it wherever a row of buttons has to survive a width nobody chose: an editor's formatting bar, a table's action column, an article's share row, an admin header, a card's footer actions, a navigation bar that has dropped to a phone, a sidebar somebody can drag narrower, a dashboard panel that resizes with the window, a toolbar that grew one button at a time until it stopped fitting on a laptop. Common asks it answers: "priority plus pattern react", "overflow menu react", "toolbar overflow shadcn", "responsive toolbar that collapses buttons", "move buttons into a dropdown when they do not fit", "collapse toolbar items into a more menu", "hide toolbar buttons on narrow screens", "react overflow set", "fluent ui overflowset equivalent shadcn", "material toolbar overflow shadcn", "three dot menu for the extra actions", "kebab menu for overflow items", "how many items fit in a container react", "measure available width react", "ResizeObserver toolbar react", "hide overflowing children with ResizeObserver", "detect overflowing flex items react", "ResizeObserver loop completed with undelivered notifications", "ResizeObserver infinite loop when hiding elements", "toolbar flickers while resizing", "items appear then disappear on load", "buttons flash before collapsing on first render", "hidden items measure zero width", "measure the natural width of a hidden element react", "how to measure an element that is display none", "overflow button width not accounted for", "last item overflows after the more button is added", "off by one gap in a flex overflow calculation", "flex items shrink instead of overflowing", "flex-shrink-0 buttons overflow", "role toolbar arrow keys react", "roving tabindex toolbar", "aria toolbar keyboard navigation", "focus lost when an element unmounts", "focus jumps to body when the focused button is hidden", "tab skips my toolbar", "no tab stop left in a roving tabindex group", "arrow keys stop working after a resize", "overflow menu accessible name", "aria-haspopup menu button react", "escape should close the overflow menu", "rtl toolbar arrow keys reversed", "icon only toolbar button accessible name", "ツールバー 折りたたみ react", "入りきらないボタンをメニューに畳む", "その他メニュー 実装", "レスポンシブ ツールバー ボタン", "幅に応じてボタンを隠す", "ResizeObserver 無限ループ", "測ってから隠すとちらつく", "role=toolbar 矢印キー", "roving tabindex フォーカスが飛ぶ", "三点メニュー 余りの操作", "フォーカスしていた項目が隠れると Tab が飛ぶ". Official shadcn/ui ships both halves of this and nothing that measures. Measured across its whole registry today — 63 items listed, 62 fetchable, `questionnaire` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated, the fifth consecutive day at that exact figure — `ResizeObserver`, `getBoundingClientRect`, `offsetWidth`, `IntersectionObserver`, `requestAnimationFrame`, `overflowItems`, `priorityPlus`, `roving`, `toolbar` and `role="toolbar"` are every one of them a zero hit. There is one `aria-orientation` in the whole registry and it is not an attribute: it is a Tailwind variant selector, `aria-[orientation=horizontal]:w-full` and five of its siblings, styling the handle in `resizable` (1,805 bytes) whose actual attribute comes from `react-resizable-panels`, an npm dependency — the same shape as the single `aria-selected` that turns out to be a `calendar` variant. So the registry contains no file that measures an element and no file that writes a toolbar's keyboard, and the two pieces it does ship are the two halves of the problem: `button-group` (2,203 bytes, `cn` and nothing else) joins the buttons visually, and `dropdown-menu` (8,408 bytes) is the menu, handed to `radix-ui` as an npm dependency — which is also why grepping official for `aria-haspopup`, `role="menu"` or `menuitem` proves nothing in either direction, since Radix supplies them at runtime. `menubar` (8,379) and `navigation-menu` (6,648) are Radix wrappers too, and both are navigation rather than folding. Nothing in the registry has a route to "how many of these fit", so everybody writes that part by hand, and **measuring your own row is a loop unless you arrange otherwise.** The reflex implementation asks the live row how wide it is, hides what sticks out, and is then asked again by its own ResizeObserver: the hidden controls now measure zero, so the row fits, so they come back, so it overflows, so they are hidden — and because every pass changes layout, the observer fires again. That is the flicker behind "ResizeObserver loop completed with undelivered notifications", and no amount of debouncing fixes it, because every pass is a correct answer to a question that should not have been asked. Two things break it here. Natural widths come from a probe that is never folded — a hidden copy of the full set, absolutely positioned and `visibility: hidden` rather than `display: none` so it still has a box to measure, on one line with `width: auto` and `max-width: none` so what it reports is the width each control wants rather than the width it was given. And the container's width cannot be changed by its contents: the root is a block-level, full-width box with `overflow-hidden`, so its width comes from its parent and a row one pixel too wide is clipped rather than pushing anything wider. Together they make the visible count a pure function of inputs that folding does not touch, so the same container width always produces the same count, React bails out of a state update to an equal value, and the observer settles after one pass — the loop is removed rather than damped. The flash is a separate problem: the measurement runs in a layout effect, after layout and before paint, so the full row is never painted and then collapsed. The honest limit is the server, which cannot measure, so an SSR'd page shows the row clipped until hydration rather than wrapped or overflowing. Four more things are easy to get wrong and are each written down in the source. The trigger's own width is charged if and only if something folds: reserve it always and a toolbar whose controls fit exactly loses its last one to a button that was never needed, reserve it never and the control that "fits" is pushed out by the trigger added after the decision, so the row overflows by exactly the width of the thing meant to prevent overflow. The gaps go with it — `n` controls alone have `n - 1` gaps, `k` controls plus a trigger have `k`, because the gap in front of the trigger is real. The controls carry `shrink-0`, which is load-bearing rather than cosmetic: a flex item that may shrink does, so without it the browser squeezes everything into unreadable slivers, the row never overflows, and the component silently does nothing. And when the row narrows, two different things go wrong with the tab stop, only one of which is about focus — the visible failure is that the control holding focus leaves the document and a browser does not move focus when that happens, it drops to `<body>`, so the next Tab starts from the top of the page; the quieter one happens whether or not anybody is looking, because a roving tabindex puts `tabIndex={0}` on exactly one control and if that control is now in the menu then nothing in the row has it and Tab skips the whole toolbar. So the stop moves on every measurement and focus is only *called* when focus was already inside, since a window being dragged narrower is not a reason to take focus away from whatever somebody is doing elsewhere on the page. The keyboard is two conventions meeting at the trigger. The row is a toolbar: one tab stop, Left and Right between controls and swapped on an RTL page, Home and End to the ends, clamping — which is the practices' description of a toolbar and the opposite of the menu underneath, where the arrows wrap. The menu is a menu: Up and Down with wrapping, Escape back to the trigger, and choosing an item returns focus there too. They meet at the trigger, the last stop in the row and the thing Down opens, so Right, Right, Down walks out of the row and into the menu and Escape, Left walks back. A horizontal arrow pressed inside the open menu is swallowed rather than left to bubble: letting it through moves the row's tab stop underneath a menu somebody is still reading, and Escape then returns focus to a control they never left. What you get: `ToolbarOverflow`, with `label` required because a `role="toolbar"` needs an accessible name and because the trigger's name is built from it — "2 more Formatting actions", so two toolbars on a page are not both "More"; `items`, most important first, because **the order is the priority** and the row keeps a prefix while the tail folds, which is what keeps the menu in the same order as the row somebody last saw (there is deliberately no per-item priority flag: it would make the visible set an arbitrary subset, turn the arithmetic into a knapsack, and leave an item nowhere near where it was); each item `{ label, onSelect, icon?, disabled?, hideLabel?, id? }`, where `disabled` becomes `aria-disabled` and never the `disabled` attribute, so an unavailable control keeps its place in the arrow walk instead of making the walk go 1, 2, 4, and `hideLabel` shows the icon alone in the row with the label as both the accessible name and the native tooltip while the menu always shows the words. `align` picks which edge the menu lines up with and mirrors itself on an RTL page. `onVisibleCountChange` reports how many controls are in the row after each measurement, and `onOpenChange` is reported from an effect rather than from inside a state setter, so a caller is told once per change instead of twice under strict mode. `fitToolbarItems` is the whole decision as a plain function of widths, available width, gap and trigger width — the part worth asserting, since every interesting case here is a width rather than a click — and `nextToolbarStop` and `rescueToolbarStop` are the row's clamping walk and the tab-stop rescue, each on its own. Per-part `className` for the row, the controls, the trigger, the panel and its items. Nothing is portalled and nothing measures the viewport: the menu is absolutely positioned inside the row, which is what a short menu under a button needs. One file, one lucide icon, and no `radix-ui`. Distinct from the neighbours: `scroll-shadow` says there is more in a scrollable row rather than folding it, and `middle-truncate` shortens one string to fit rather than deciding which whole controls survive — it is the other component here that measures text against a box, and the probe below is the same idea at the scale of a row. `split-button` is one action with its variants under an arrow beside it, and this composes with it: the overflow menu's wrap rule is `split-button`'s own `nextMenuIndex`, imported rather than written twice, so the two menus in this registry cannot drift apart. `bulk-action-bar` is a fixed set of actions over a selection, `keyboard-shortcuts` is the sheet that lists what the keys do, `command-palette` is a searchable dialog over everything reached by a shortcut, and `filter-chips`, `segmented-control` and `multi-select` all pick values rather than running actions. `column-resizer` and `sticky-table-header` measure a table; this measures itself.
Two lists side by side and the controls that move rows between them — the dual-listbox or "pick list" pattern: what is available on the left, what has been chosen on the right, and four named buttons in the middle. Reach for it wherever the question is which of these and the answer is long enough that seeing what is in and what is out at the same time beats a dropdown: permissions, scopes and API-token access on an admin screen, assigning roles or groups to a user, picking the members of a team, project, channel or on-call rotation, choosing which columns a table shows or which fields an export, report or integration syncs, building the recipient list of an email, invite or notification, selecting which metrics a dashboard plots, which locations, stores or warehouses a rule covers, which tags, categories or facets hang off a post or product, which features a plan includes, which modules a course or curriculum contains, moving players between a squad and a bench, and the "applies to" of any policy, coupon or automation. Common asks it answers: "transfer list", "transfer list react", "dual listbox", "dual list box react", "shadcn transfer list", "shadcn dual listbox", "pick list component", "picklist react", "two list multi select", "move items between two lists react", "move selected items left and right", "arrow buttons between two lists", "available and selected lists react", "side by side multi select", "MUI TransferList equivalent", "antd Transfer component alternative", "PrimeReact PickList alternative", "react-dual-listbox alternative", "react-widgets Multiselect two columns", "shadcn assign permissions ui", "permission assignment component", "role assignment two lists", "column picker react", "choose which columns to show component", "table column chooser react", "field picker component", "recipient picker react", "add and remove between lists", "select all and move all button", "move all visible rows", "filter both lists before moving", "search inside transfer list", "disabled items in transfer list", "locked rows cannot be moved", "multi select listbox accessible react", "aria-multiselectable listbox example", "aria-activedescendant react listbox", "role listbox multiple selection", "accessible listbox keyboard react", "space to select enter to move listbox", "focus lost after moving an item between lists", "disabled button steals focus react", "focus jumps to body when button becomes disabled", "where should focus go after moving an item", "aria-activedescendant points at removed option", "screen reader silent after moving items", "announce move between lists aria-live", "order changes when I move an item back", "item appended to end instead of its original position", "list reorders itself after undo", "move button says right arrow", "accessible name for arrow buttons", "二つのリストで項目を行き来させる", "振り分け UI react", "権限の割り当て 画面", "ロールの割り当て コンポーネント", "表示する列を選ぶ UI", "左右のリストで移動", "選択済みと未選択を並べて見せる", "dual listbox 実装", "移動ボタンの読み上げ名", "戻したら並び順が変わる", "リストボックス 複数選択 アクセシビリティ", "全部移動 ボタン", "フィルタしてから移動". Official shadcn/ui has nothing of this kind, and the measurement is more surprising than the usual zero. Taken today over its whole registry — 63 items in the catalogue, 62 fetchable (questionnaire is indexed and 404s on both style tracks), 223,287 bytes of files[].content concatenated — TransferList, transfer-list, dualListbox, selectedItems and moveRight are all zero hits, which is expected. So are listbox, role="option", aria-activedescendant, aria-multiselectable and the word multiple: official ships no listbox markup at all, Radix owning it inside select (6,343 bytes) and combobox (9,169 bytes), so the whole ARIA pattern this component is built on appears nowhere in the registry to copy from. The single aria-selected in all 223KB is a Tailwind variant in calendar styling react-day-picker's output (aria-selected:text-muted-foreground) — a styling hook, not a list. The things official has that hold more than one answer are a different tool: checkbox is 1,204 bytes of one control per option, toggle-group 2,309 bytes of always-visible buttons, and both stop working somewhere around a dozen options. Five things go wrong in the version everybody writes first, and four of them are invisible to a mouse. First, where the cursor goes after a move — two different failures wearing the same face. Empty a side with the button you are holding and that button can no longer act: given the disabled attribute it stops being focusable while it holds focus, so the browser drops focus to <body>, the next Tab restarts at the top of the document, and a screen reader says nothing. Every unavailable control here is aria-disabled instead, so it keeps its tab stop, keeps the focus it had, and announces itself as unavailable. Move from inside a list, though — Enter on a selection, a double-click — and focus never left the listbox; what is lost is aria-activedescendant, still pointing at an option that is now on the other side, so the ring is drawn and there is nothing to read. nextActiveAfterMove is one subtraction rather than two rules: the new position is where the cursor was minus however many removed rows were above it, clamped to the end. That answers both the row under the cursor having moved away (it lands on the row that took the slot, so emptying a list is presses in one place) and the row under the cursor having stayed (it follows that row), which is the case a rule written only for the first gets wrong — move a selection from the top of a list while standing in the middle of it and "go to the first vacated slot" yanks the cursor to the top for no visible reason. Second, the two sides are listboxes and not columns of checkboxes: aria-multiselectable with aria-selected on every option is a list of which several are chosen, where checkboxes are independent controls, one tab stop each, announced one at a time with no sense of how many there are. Third, the order rows come back in. Push onto the destination and a row moved right then left lands at the bottom of the list it came from, so changing your mind rearranges the thing you were reading; do it twice and the order is one nobody chose. Neither side stores an order here — both are items filtered, so the canonical order is the one you sorted items into and a move is reversible in the only sense that matters. The emitted array is in that order too, so the same choice is always the same array and nothing downstream diffing it sees a change that is not one. Fourth, the names of the move buttons: four arrows announce as four arrows, and two of them are "move the three I picked" and "move all four hundred". Each is named for its effect and its true count — "Move 3 selected to Granted", "Move all 12 to Granted" — where the count is what will actually move, locked rows excluded, so the name never over-promises. Fifth, both empty states: an empty side keeps its tab stop, because the side nobody can reach by keyboard is exactly the one about to be moved into, and it says whether it is empty or merely filtered to nothing, which are different problems. A move is also announced through a polite live region, because it changes two lists and no field's value — without it the only evidence a press did anything is that two numbers changed. What you get: TransferList, taking items (every row, in the order both sides are shown in) and the chosen values controlled with value + onChange (handed the next array and a { to, values } saying what went where) or uncontrolled with defaultValue; per-item disabled for rows that are shown, readable, focusable and never moved; a filter box on each side, on by default and off with searchable={false}, which narrows its side for every purpose so nothing you cannot see is ever moved, while a selection you filtered out of sight is kept rather than destroyed; availableLabel and chosenLabel, the one pair of words every button, count and announcement is built from ("All permissions" and "Granted" rewrites the lot), with labels to override any single one or translate it; label, description, name and form for an ordinary form POST as one hidden input per value — disabled mirrored onto them, because a disabled control is left out of the form data and a hidden input that was never told so is not; and per-part className for the group, the listboxes, an option and the buttons. The keyboard is complete on both sides: one tab stop each, arrows to move the cursor, Space to pick, Shift+Arrow to extend by adding, Home and End, Ctrl/Cmd+A for everything movable on screen, Enter to move what is picked (or the row under the cursor when nothing is), and a double-click for one row. transferListSides, transferValues, movableValues and nextActiveAfterMove are exported as pure functions, so the decisions that are actually hard — which side a row is on, what the next array is, what will really move, and where the cursor lands — are assertable and usable headless; a chosen value that no item claims is carried through a move untouched, so a paginated or lazily loaded items does not silently drop choices made on another page. One file, five lucide icons, no Radix and no state library. Distinct from its neighbours by what it shows rather than what it returns: multi-select picks from a list inside one field and shows the chosen as badges, nested-checkbox selects from a hierarchy, tag-input takes free text that did not exist before, and tree-view is hierarchy you navigate rather than select — none of them puts both sets on screen at once, which is the thing this is for. sortable-list is the one to pair it with rather than replace it with: this answers which, not in what order, and if the order of the chosen set is itself the decision then feed one into the other. bulk-action-bar acts on rows already selected, and filter-chips shows which conditions are in force.
The nested list you can open, close and walk with the arrow keys: a file explorer or file tree, a folder or directory tree, a category or taxonomy picker, an org chart, an API-schema browser, a docs sidebar with nested sections. Common asks it answers: "tree view", "tree component", "file tree", "folder tree", "directory tree", "file explorer sidebar", "nested list with expand/collapse", "collapsible tree", "expandable folder list", "VS Code-style explorer", "category tree", "org chart tree". shadcn/ui ships no tree of any kind — its collapsible is one open/closed section and its sidebar nests menus without the tree semantics — so this gets hand-rolled every time, and the part that gets dropped is always the keyboard. Pass a `data` array of `{ id, label, children?, icon? }`: a node with a `children` array is a parent (an empty array is an empty folder, which still opens), a node without one is a leaf. Open state and selection are each controlled (`expandedIds` / `selectedId` plus `onExpandedChange` / `onSelect`, which hands you the whole node) or uncontrolled (`defaultExpandedIds` / `defaultSelectedId`), so it drops into a router-driven sidebar or runs on its own. It is the real ARIA tree pattern, not a pile of nested collapsibles: role=tree / treeitem / group with aria-expanded, aria-selected and aria-level/posinset/setsize, and a roving tabindex so the whole tree is one Tab stop instead of one stop per row. Up/Down walk only the rows actually on screen, Right opens a parent and then steps into it, Left closes it or jumps out to the parent, Home/End hit the ends, Enter/Space select, and type-ahead jumps to the next row starting with what you typed (repeat a letter to cycle). Three details that are easy to get wrong are handled: closing a subtree that contains the focused row hands focus back to the row being closed instead of dropping it on <body>; the row is named by its own label via aria-labelledby, because a treeitem owns its child group and a name computed from contents would read the entire subtree as one row's name; and the disclosure arrow is a click target rather than a nested <button>, since a treeitem must not contain its own focusable elements. Renders folder/file icons by default (`showIcons={false}` for category or org trees), `indent` sets the per-level offset, and per-node `icon` overrides a single row. Styled with shadcn tokens (accent, muted-foreground, ring) so it follows light and dark themes; lucide-react is the only dependency, with no Radix and no state library. Distinct from command-palette, which is a flat searchable launcher: this is for structure you navigate rather than a name you already know. Distinct from json-viewer, which takes the parsed JSON value itself and renders its keys, types and entry counts: reach for that one to display a payload you did not author, and this one when you have your own hierarchy to express as `{ id, label, children }` nodes.
The confirmation step in front of an irreversible action: the person has to type the resource's own name ("acme-prod") before the destructive button turns on. Reach for it wherever a misclick would be unrecoverable and a plain "Are you sure?" is too cheap: a settings page's danger zone, deleting a project, repository, workspace, organisation, team, cluster, database, bucket, index or environment, dropping a table or wiping seed data, removing a member or transferring ownership, revoking an API key, token or deploy key, rotating a secret, disconnecting an integration, force-pushing or deleting a branch, emptying a trash or archive, closing an account, and cancelling a paid subscription. Common asks it answers: "type to confirm", "type the project name to delete", "type DELETE to confirm", "confirm delete by typing name", "delete confirmation input react", "github style delete confirmation", "danger zone component react", "danger zone settings page", "destructive action modal react", "disable delete button until text matches", "confirm by typing resource name", "delete repository confirmation ui", "irreversible action confirmation react", "shadcn delete confirmation", "shadcn danger zone", "shadcn alert-dialog type to confirm", "react confirm delete dialog", "double confirmation delete react", "destructive button guard", "削除確認 入力", "プロジェクト名を入力して削除", "取り消せない操作の確認", "危険な操作 確認ダイアログ". Official shadcn/ui gives you alert-dialog, which is the shell and nothing else: the typed match, the disabled-until-it-matches wiring, the field that a phone must not autocapitalise, and the announcement when the button finally arms are left to be written again on every danger zone. This packages exactly those, and it sits inside the dialog or card you already have, so it assumes nothing about which official components are installed. The reason to install one rather than write the three lines is that the three-line version is wrong in four ways that only show up on somebody else's machine. A comparison without trimming fails for the pasted name, and a name copied out of a console or a URL bar routinely arrives with a trailing space or newline, so the person is looking at the right text and the button will not arm; both sides are trimmed here. An empty phrase matches an empty field under ===, so a dialog opened before its data has loaded arms the delete while the field is untouched; an empty phrase never matches here. On a phone the first letter is capitalised, autocorrected and spellchecked by default, which makes an exact match literally impossible to type, so the field opts out of autocomplete, autocorrect, autocapitalisation and spellcheck. And a live region that is inserted together with its text is not reliably announced, so a notice rendered only once the text matches swallows the very update it exists to make; the region here is always mounted and speaks the moment the button arms. The button is genuinely disabled rather than aria-disabled. Nothing is lost by that even though screen readers skip a disabled control, because the label says what to type, the description explains that the button is waiting for it, and the live region reports the change. It renders as a real <form>, so Enter submits and, inside a dialog, focus lands on the field on open with no extra wiring. Pass phrase and onConfirm and you are done; caseSensitive={false} lets "delete" pass for "DELETE", pending mirrors your mutation so the field locks and the button swaps to pendingLabel, and label, description and actionLabel are all overridable for another language or a softer tone. Every colour is a shadcn token (border-input, destructive, muted-foreground, ring), so light and dark follow on their own, and it depends on nothing but your cn util. Within pulld it is the heavy end of a pair: confirm-button is the two-step click for something cheap and reversible, and this is the guard for what cannot be taken back. unsaved-changes-guard stops you leaving work behind, where this one stops you destroying it.
A number box with a unit beside it that settles what happens to the number when the unit changes. Reach for it wherever a form asks for a length rather than a count: a spacing, padding or radius field in a theme or design-token editor, a font-size control in a settings panel, page margins for a print or PDF export, a width or breakpoint in a layout builder, a canvas or image size, a CSS value in an admin or CMS style panel, a chart axis or gutter, a wrapper for the one CSS length a white-label tenant is allowed to set. Common asks it answers: "unit input react", "shadcn unit input", "number input with unit dropdown", "css length input react", "px rem input react", "px to rem converter component", "input with unit selector shadcn", "length field with units react", "css unit picker react", "convert px to rem in javascript", "CSS.px(16).to('rem') throws", "Cannot convert to px typed om", "CSSUnitValue to rem TypeError", "how many px is 1rem javascript", "get root font size javascript", "getComputedStyle fontSize rem base", "px rem round trip loses value", "1.06rem is not 17px", "value changes when I switch units", "unit dropdown changes my number", "width 16 not working css", "unitless length invalid css", "css unitless zero only", "input type number step per unit", "arrow key step 0.25 rem", "0.30000000000000004 spacing input", "parse 1.5rem in javascript", "split number and unit javascript", "paste 1.5rem into input", "単位付き 数値入力 react", "px rem 切り替え 入力欄", "css 長さ 入力 コンポーネント", "単位を変えたら値が変わる", "design token editor length input", "tailwind spacing scale input", "css custom property length input", "css variable px rem editor", "style dictionary token input react", "token studio px rem", "storybook control for css length", "theme editor spacing field react", "border radius input react", "letter spacing input react", "clamp value input react", "viewport unit input vw vh react", "print margin input mm inch", "デザイントークン 長さ 入力", "余白設定 px rem 切り替え". **Changing the unit is the whole component, because there are two different right answers and picking silently is what makes a field untrustworthy.** Converting keeps the rendered length and moves the number (16px becomes 1rem); reinterpreting keeps the number and moves the length (16px becomes 16rem, which renders at 256px — sixteen times bigger). Both are what somebody means, in different moments, so `behavior` names the one you want and the default converts. **It will not convert a unit it cannot account for, and that refusal is the platform's own.** Typed OM converts any fixed unit into any other — measured in Chrome 152, `CSS.px(96).to("in")` is `1in` and `.to("cm")` is `2.54cm` — and throws `TypeError: Cannot convert to px` on rem, em, %, ch, ex, lh, vw, vh, vmin and vmax, all ten of them, because a number does not know how big it is. So the fixed units convert with no help, and the relative ones convert only once `base` says what they are worth; without it the number is carried across unchanged and the field says it was carried, rather than dividing by an assumed 16 and moving a value nobody touched. The base is the caller's because it is a fact about the page, and because it is live: setting the root font-size to 20px makes `width: 1rem` compute to 20px on the next read, so a component that cached 16 would be wrong from then on. `readBase(element)` is exported to read the current ones off the document in a line. **A round trip gives back what was typed, which rounding harder does not achieve.** Over 1 to 200px against a 16px root, rounding a px→rem→px trip to 2 decimals returns the original for 50 of the 200 — 17px leaves as 1.06rem and comes back 16.96px — and against a 15px root, somebody who set `html { font-size: 15px }`, exactly 65 of 200 survive at 2, 3 and 4 decimals alike, because 1/15 does not terminate in decimal and more places never recover it. The number is therefore remembered per unit for as long as the edit lasts and restored on the way back, not recomputed; typing is a new intention and drops the memory. **A bare number is refused except as zero, which is what CSS does.** Measured, `CSS.supports("width", "16")` is false while `"0"`, `"0.0"` and `"-0"` are all true, and assigning the unitless string to `element.style.width` is a silent no-op that leaves the previous value standing — so a non-zero number with no unit is marked invalid and never posted, while `""` can still be offered in the list for the properties where it is legal (`line-height: 1.5` and `z-index: 3` both measured valid). **The arrow keys step by the unit on screen**, 1 for px and 0.25 for rem and em out of `DEFAULT_STEPS`, with Shift taking ten at once, and each step rounds to the decimals in play because ten additions of 0.1 in IEEE 754 land on 0.9999999999999999. `minPx`/`maxPx` are given in pixels so one bound means one thing in every unit; they are reported rather than silently applied while typing, and the arrow keys stop at them. Pasting `1.5rem` into the number box splits it and moves the unit, since that is how a value copied out of devtools arrives; unit identifiers are matched case-insensitively (`16PX`, `1q`) and the canonical spelling comes back. Parsing is stricter than `Number` on the reasoning this registry's number-range-input gives: `1e3`, `0x10`, `Infinity` and `1,5` are all numbers to `Number` and none of them is a length somebody typed, so they are refused and the text stays on screen to be corrected instead of being deleted out from under the reader. The two controls are one `role="group"` with a label, each separately labelled, the box is `type="text"` with `inputMode="decimal"` rather than `type="number"` — a number input discards text it cannot parse, so `16q` would vanish instead of being explained — one polite status line carries either the resolved pixel length or the problem and is wired to the box with `aria-describedby`, `aria-invalid` follows the actual fault, and a `name` posts canonical CSS (`1.5rem`) through a hidden input and posts nothing when the value is one it would not stand behind. `parseUnitValue`, `convertLength`, `resolvePx`, `toCssLength`, `stepValue` and `ABSOLUTE_PX` are exported as plain functions so the route handler that receives the string reaches the same answer the field showed. The fixed table is derived from cm the way CSS defines it rather than written out, which is also the only form that agrees with the engine bit for bit: `96/25.4` is 3.7795275590551185 in IEEE 754 where Chrome reports 3.7795275590551176. Official shadcn/ui has nothing of the kind and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `convertUnit`, `parseUnit`, `unitless`, `CSSUnitValue`, `getComputedStyle`, `fontSize`, `toFixed`, `"rem"`, `'rem'` and `unitSystem` are every one of them a zero hit; its `input` is a bare text box and `input-group` only puts ornaments either side of one, and official never reads a computed style anywhere. Within pulld it is the length end of the field family: `number-input` counts things with steppers, `number-range-input` holds a from-and-to pair, `currency-input` formats money and `duration-input` reads a span of time — each of those is one unit that never changes, and this is the one where the unit is part of the value. No dependencies beyond React and your `cn`.
A leave guard for a form with unsaved changes: it holds the departure, asks whether the work should be discarded, and lets it through only when the answer is yes. Reach for it wherever a screen holds work that only exists in the browser: a long or multi-step form, a profile or settings page, a post, comment or article editor, an invoice, order or booking being filled in, a canvas, diagram or query builder, an upload that has been configured but not started, and any modal a user can wander out of. Common asks it answers: "unsaved changes warning react", "are you sure you want to leave this page", "beforeunload react hook", "useBeforeUnload", "prompt before leaving page react", "block navigation react router", "useBlocker alternative", "next.js unsaved changes warning", "next.js prevent navigation", "confirm before leaving form", "dirty form guard", "warn user before closing tab", "onbeforeunload not working", "react router prompt replacement", "shadcn unsaved changes dialog", "discard changes dialog", "route change confirmation react". The reason to install one rather than write four lines is that the four lines guard the wrong door. beforeunload fires when the browser unloads the document — closing the tab, reloading, typing a new URL, following a link off the site. Clicking a next/link or a React Router Link is none of those: the document stays exactly where it is, the router swaps what is rendered, and the half-filled form is gone without the browser ever being consulted. So the guard everyone ships protects the tab button and lets every single in-app route change walk straight past it, which is the way people actually lose the work. beforeunload is not straightforward on its own terms either. The spec settled on preventDefault() while older engines only look at returnValue having been set, so a handler that does one of the two is silently dead in some browsers. Custom wording is discarded by every current browser, so the message you actually control is the in-app one. And nothing fires at all on a page the user has never clicked or typed into, because browsers require sticky activation before they will interrupt a departure. This component does the browser half correctly and then covers the two departures it cannot see, each with the mechanism that is right for it. Link clicks are caught by a capture-phase listener on document, which runs before React's root listener and therefore before any router's handler; both preventDefault() and stopPropagation() are called, because routers differ on whether they check defaultPrevented and stopping the event short of the React root is true of all of them. Answering "discard" replays the original click on the same anchor, so the router handles it exactly as it would have — no full page reload, no reimplementation of routing. Back and forward are caught with the Navigation API's navigate event, the only thing in a browser that can actually refuse a traversal: popstate is announced after the history entry has already changed, and the usual workaround of pushing a sentinel entry to have something to pop leaves a duplicate entry and a back press that does nothing for the rest of the session. That trade is refused here rather than hidden, so the reach is stated plainly: where window.navigation is missing, back is not intercepted, and the way to cover it — along with navigation your own code starts — is the guard prop, which takes a blocker from your router (React Router's useBlocker drops straight in) and stands the built-in interception down while keeping the browser-level warning. Just as deliberate is what is never held: a router.push the app itself makes, because the redirect after a successful save is exactly that and blocking it traps someone on a form they have already submitted; cross-origin links, which are a real unload and which beforeunload already covers; hash links, downloads, target="_blank", and cmd, ctrl, shift or middle clicks, every one of which leaves this page where it is. Official shadcn/ui has nothing in this area — beforeunload, unsaved, dirty and blocker appear in none of its sixty-three components, and it ships no navigation blocking of any kind. Within pulld it is distinct from confirm-button, which guards a destructive action somebody deliberately clicked; this one interrupts a departure nobody thought of as destructive. It pairs with save-status and form-error-summary on the same screen. The dialog is an alertdialog because it interrupts rather than being asked for, and focus lands on "keep editing" rather than on discard, so an Enter press already on its way to the page cannot answer with the destructive choice; Escape does the same as keep editing, and there is no third way out that would leave the navigation in limbo. Once a departure has been answered the browser stops asking the same question, and starts again the moment the user touches the page still holding unsaved work. useUnsavedChanges() is exported for a dialog of your own, useBeforeUnload() for the browser half alone, every colour is a shadcn token so it follows light and dark, and the whole thing is one file with no dependency beyond the icon.
The list of files under a dropzone or file picker — one row each with the file name, its size, a progress bar while it uploads, an error with a retry button when it fails, and an X to drop it from the queue. Use it on any screen that accepts files: an attachment picker, an image or avatar upload, a CSV/spreadsheet import step, a document or PDF upload, a bulk media drop, or an import wizard. Common asks it answers: "file upload list", "upload queue", "show selected files with progress", "file list with remove button", "upload progress bar per file", "attachment list", "retry failed upload", "Dropbox/Gmail-style upload rows". shadcn/ui ships nothing that tracks an upload: its attachment component renders a file that is already attached — media, title, actions — with no status, no progress and no retry, and its progress primitive is a single bar with no notion of a file, so the row layout, the byte formatting, the per-file progress and the failure affordance are hand-rolled every time. It pairs with the file-dropzone component, which hands you a File[] and deliberately stops there: this is the half that shows what happened to those files. Pass an `items` array of `{ id, name, size?, status, progress?, error? }` where status is pending | uploading | done | error; omit `progress` and the bar goes indeterminate for uploads with no known length, and an empty array renders nothing so you can mount it unconditionally next to your queue state. It is presentational on purpose and never uploads anything — you keep the requests, the concurrency, the cancellation and the retry policy, and pass `onRemove`/`onRetry` to get the buttons. Accessibility is where a queue usually goes wrong and this one is built around it: progress sits in a role=progressbar, which is not a live region, so a file crawling from 1% to 100% does not narrate every tick; instead an always-mounted role=status region announces only the rows that just finished or just failed, batched into one message per change; the first render is treated as the starting state, so a list that mounts with finished rows stays silent; and every remove/retry button carries the file name in its accessible name, because a column of buttons all called "Remove" is unusable without sight of the row. Sizes are formatted to KB/MB/GB with tabular numerals, long names truncate with a title tooltip, and it is styled with shadcn tokens (muted-foreground, destructive, primary, accent, ring) so it follows light and dark themes; lucide-react is the only dependency. Distinct from save-status, which is a one-line indicator for a single background save, and from progress-ring, which is one circular meter: this is the multi-file queue.
A long list that only puts the rows you can see into the DOM: five thousand rows render as about thirty nodes, so the page stops taking seconds to paint and scrolling stops stuttering. Reach for it on an admin table or data grid, a log, audit or event viewer, chat and message history, a notification or activity feed, search results over a big local array, a file or asset browser, a leaderboard, a transaction or order history, a select or combobox with thousands of options, a long picker of countries, currencies or timezones, and any list where you already hold every row in memory. Common asks it answers: "virtual list react", "virtualized list react", "list virtualization", "windowing react", "react-window alternative", "react-virtualized alternative", "react-virtuoso alternative", "virtua alternative", "@tanstack/react-virtual", "TanStack Virtual without the wiring", "virtual scrolling react", "infinite list vs virtual list", "render 10000 rows react", "render 100k rows", "long list is slow to render", "list lags when scrolling", "scroll performance long list", "page freezes with large table", "only render visible items", "list virtualization with dynamic row heights", "variable height virtual list", "virtual list unknown row height", "measure row height virtual list", "virtual list scroll jumps", "scroll position jumps when prepending items", "chat keeps jumping when loading older messages", "maintain scroll position when adding items above", "virtual list loses focus", "focus lost when row unmounts", "screen reader virtualized list", "aria-posinset aria-setsize list", "accessible virtual list", "virtual list scrollToIndex", "restore scroll position react list", "shadcn virtual list", "shadcn table 1000 rows slow", "next.js virtualized list", "virtual list app router", "仮想スクロール react", "大量の行 描画 遅い", "リスト 仮想化", "スクロール位置 ずれる 追加". **shadcn/ui ships no virtualization, and that is a measurement rather than an impression:** fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings virtual, virtualiz, windowing, overscan, ResizeObserver, aria-posinset, aria-setsize, scrollToIndex, getVirtualItems, estimateSize and translateY are every one of them a zero hit. Its `table` renders every row you hand it and its `scroll-area` hands scrolling to Radix without measuring anything, so this gets wired by hand against TanStack Virtual or react-window each time — and the same four things break every time. **Focus survives.** The row you tabbed into stays mounted after it scrolls out of the window, instead of being unmounted under you and dropping focus to the top of the page — the failure that makes a keyboard user lose their place in a list they were working down. **Screen readers get the real position.** Every row carries `aria-posinset` and `aria-setsize`, so it is announced as "item 4,213 of 5,000" rather than as a count of the handful that happen to be mounted, and the spacer holding the scroll height is marked presentational so the list and its items stay related. A virtualized list without this tells a screen reader a number that is simply false. **The view does not jump.** Rows are measured as they mount with a `ResizeObserver`, and when a row above the viewport turns out taller than the estimate — or older rows are prepended — the scroll offset is corrected against a row-keyed anchor in a layout effect, before the browser paints. That anchor is why prepending older chat messages keeps the message you were reading exactly where it was, which is the single most complained-about behaviour in hand-rolled versions. **And positions can be restored**, via `defaultScrollOffset` plus a ref handle with `scrollToIndex(index, "auto" | "start" | "center" | "end")`, `scrollToOffset` and `getScrollOffset` — so returning from a detail page lands where the reader left. Rows may be any height and nothing has to be declared up front; `estimateItemHeight` (default 48) is only the guess used before a row has been measured, and `overscan` (default 4) sets how many rows are kept mounted beyond the edges. Controlled by `count` plus a render function — `children` is called with an index, so the data can live anywhere — with `itemKey` for stable identity, `onScroll` and `empty`. Defaults to `role="list"` / `listitem`; pass `role="listbox"` and `itemRole="option"` when the rows are selectable. Set the height with `className` (the default is `h-72`); rows are absolutely positioned, so give them padding rather than a vertical margin. Vertical only, and find-in-page reaches mounted rows only, which is inherent to windowing rather than a shortcoming of this one — if a reader has to be able to Cmd-F the whole list, do not virtualize it. Styled with shadcn tokens so it follows light and dark themes, and it ships with **no dependencies at all** — no Radix, no virtualization library. Within pulld it is the counterpart to infinite-scroll, and the two are easy to confuse: that one fetches the next page when the reader nears the end and is about *where the data comes from*, while this one is about *how many rows exist in the DOM*, over data you already hold — a list that loads endlessly and never virtualizes gets slower with every page, and the two compose. Beside it sit sticky-table-header and sort-header for long tables, and table-row-expander for rows that open.
A toggle that stops the screen going dark while somebody is looking at it but not touching it, and — unlike every version written by hand — keeps on being true about whether the screen is actually being held awake. Reach for it wherever the page is being read rather than operated: a recipe followed with both hands busy, a barcode, QR code or boarding pass held up at a till or a gate, sheet music or guitar tabs on a stand, a presentation or kiosk view, a workout, cooking or interval timer counting down, an inspection or picking checklist walked through on a phone, turn-by-turn directions, a live scoreboard or auction, a long article or PDF being read, a dashboard left up on a wall display, a video call or a camera feed. Common asks it answers: "keep screen awake react", "prevent screen from sleeping web", "screen wake lock react", "wake lock api react", "navigator.wakeLock react", "useWakeLock hook", "react-screen-wake-lock alternative", "NoSleep.js alternative", "stop phone screen turning off website", "keep display on pwa", "wake lock toggle shadcn", "shadcn keep screen on", "wake lock released when tab hidden", "wake lock stops working after switching tabs", "wakeLock request NotAllowedError", "navigator.wakeLock is undefined", "wake lock not working on http", "screen-wake-lock permissions policy iframe", "release wake lock on unmount", "画面が消えないようにする react", "スリープ防止 ウェブ", "スクリーンスリープ 無効化 react", "タブを戻すと画面が消えてしまう". Official shadcn/ui has nothing of the kind, and the measurement is not close. Fetching all sixty-three registry entries today — sixty-two are fetchable, questionnaire alone 404s, and nine of the newer ones are only served on the new-york-v4 style track, so a probe of new-york and default silently misses them — and concatenating the component sources themselves rather than their JSON envelopes gives 223,287 bytes. In it, wakeLock, WakeLock, requestWakeLock, WakeLockSentinel, screen-wake-lock, keepAwake, NoSleep, visibilityState and visibilitychange are every one of them zero hits. Official has no component that reacts to the tab being hidden at all, so an agent asked for this writes it from scratch, and the version it writes has one specific bug in it. The bug is a single `const [isOn, setIsOn] = useState(false)` flipped inside the click handler, which makes the toggle a claim about the lock rather than a reading of it. It demos perfectly, because a demo never leaves the tab. Then the user glances at a message and comes back — and the browser releases a screen wake lock the moment the document stops being visible, silently, with no callback and nothing in the console. The toggle still reads "Screen stays on"; the phone in their hands starts dimming on schedule. Nothing in the UI ever admits it. That is the failure this component is built around: the lock has to be taken again on the way back, and the way back is `visibilitychange`. So the state here is two things, not one. `isEnabled` is what the user asked for and it survives the hidden stretches; `isActive` says whether a sentinel is being held right now, and it is written only by the sentinel’s own `release` event — the event the browser fires when the tab is hidden, the window is minimised, the battery gets low or the OS simply takes it back. On the element they appear as `aria-pressed` and `data-active`, so `aria-pressed="true"` with `data-active="false"` is a readable, honest state rather than a lie. Note what this does not need, because it is the exact opposite of the Fullscreen API and the difference is load-bearing: `wakeLock.request()` does not require a user gesture. It requires the document to be visible. That is what makes the re-acquire possible at all — there is no click to hang it on when somebody switches back to a tab — and it is also why the request fails in places a click would have got through. Asking while hidden is a guaranteed NotAllowedError, so it is not asked; and a NotAllowedError that arrives because the tab went away mid-request is a race, not a refusal, so it is not reported — a version that surfaces it puts an error in front of the user every time they switch tabs. A refusal that arrives while the page is visible is the real thing — an iframe that was not granted `allow="screen-wake-lock"`, or a browser that has decided no — and there the setting goes back off and `onWakeLockError` fires, rather than retrying on a timer that would spin forever. TypeScript will not help you here and will in fact mislead you. Since TS 5.x, lib.dom.d.ts declares `readonly wakeLock: WakeLock` on Navigator — not optional — so `navigator.wakeLock.request("screen")` type-checks cleanly and then throws `TypeError: Cannot read properties of undefined` at runtime wherever it is absent. This is a secure-context API, so that includes every page served over plain http: the staging box on an internal IP, the phone opening your dev server by LAN address. Detection is written by hand with an `in` check, it never reaches the render — so the server and the first client render agree and nothing hydrates twice — and where the API is missing the control stays in the tab order with `aria-disabled` and an accessible name that explains itself, rather than taking the `disabled` attribute that would remove it from the tab order so nobody ever hears why. The rest is the lifecycle nobody gets to on the first pass. Only one request is ever in flight, because a hidden/visible flap can fire two before the first settles and every sentinel but the last would be leaked, held with nothing left pointing at it. A sentinel that arrives after the user has switched the toggle off is released immediately instead of kept. Unmounting hands the lock back, because the sentinel is owned by the document rather than by the component: navigating from the recipe to the checkout inside a single-page app would otherwise leave the screen pinned awake with no control left to turn it off. The API: `WakeLockToggle` takes `defaultEnabled`, `onLabel`, `offLabel`, `unsupportedLabel`, `iconOnly`, `onEnabledChange` and `onWakeLockError` — named that way rather than `onChange` and `onError` because both of those are native DOM attributes React defines on every element, and a prop by either name collides the moment the options are spread onto the button. `useWakeLock()` returns `{ isEnabled, isActive, isSupported, error, enable, disable, toggle }` for a control you lay out yourself, and `isWakeLockSupported()` is there when you would rather hide your own. Within pulld it sits with the other components that watch what the browser is doing behind the app rather than what the user is doing in it: idle-timeout is the mirror image, ending a session when nobody is there, network-status reports a connection that changed without being asked, countdown and save-status are the things most often on screen while this is on, and fullscreen-button is the other half of a kiosk or presentation view. Distinct from a CSS or meta-tag trick: this is the browser’s own screen wake lock, and it lets the display dim on the browser’s terms the moment the setting is turned off. One file, one dependency (lucide-react for the two icons), every colour a shadcn token, so light and dark follow on their own.
A week of opening hours in one editor: seven rows, each a switch plus an opening and a closing time, handing back a plain object keyed by weekday. Reach for it wherever a form asks *when in the week* rather than when on the clock — store, shop and restaurant opening hours; a support desk’s staffed window; delivery, pickup and collection slots; a shift roster or rota template; a staff member’s bookable availability; clinic, gym, salon, library and office hours; per-day quiet hours or do-not-disturb; and the days and times a scheduled job, digest or backup is allowed to run. It settles the three rules that hand-rolled versions get wrong. **A closed day is `null`, never `00:00`–`00:00`** — mix those two and “closed on Sunday” becomes indistinguishable from “open around the clock on Sunday”, the one mistake in this domain that reaches customers. **A closing time earlier than the opening time is the night, not a typo** — 22:00–02:00 is the bar that shuts at two, measured across midnight as 4h instead of being flagged invalid. **Equal opening and closing times mean the whole day**, so “open 24 hours” stays expressible without inventing a third state. Each row says in words which of the three it read, as you type. The week is ordered by data, not by hand: Sunday first in en-US and ja-JP, Monday in de-DE and fr-FR, Saturday in ar-EG, taken from `Intl.Locale`’s week info, with the day names from `Intl.DateTimeFormat` — or pin it yourself with `weekStartsOn`. The fourteen time fields are this registry’s `time-input`, so each one follows the reader’s clock (12- or 24-hour, the AM/PM wording, the segment order) and is typed with the keyboard rather than picked from a dropdown. “Apply to all” copies one day across the week; a day switched off and back on returns the hours that were typed instead of a default; `incompleteDays(value)` lists the days that are open but only half filled in, which is what to check before saving. With `name` set, a hidden input carries the week as JSON, so `null` survives a native form post — which no flat field encoding manages. Common asks it answers: “opening hours input”, “business hours picker”, “store hours editor”, “hours of operation form”, “weekly schedule input”, “day of week time picker”, “operating hours component”, “working hours editor”, “availability editor”, “weekly availability picker”, “shift schedule input”, “rota editor”, “open closed per day”, “per-day time ranges”, “overnight hours input”, “quiet hours per day”, “office hours editor”, “restaurant hours input”, “shadcn opening hours”, “shadcn business hours”, “react opening hours picker”, “react business hours component”, “business hours without a library”. shadcn/ui has no surface for this: its `calendar` answers a date on a month grid, `item` and `field` are layout kits for assembling a row yourself, and fetching the source of all 63 items in its registry and grepping them for the clock — `type="time"`, `hourCycle`, `hour12`, `dayPeriod`, `toLocaleTimeString`, `hour`, `minute` — returns nothing at all. Distinct from this registry’s `time-input`, which is the single field this one places fourteen of, and from `cron-expression`, which reads a cron string and explains when it fires rather than letting a person edit a week by hand. One span per day: a day with a midday break is two spans, and that is deliberately out of scope.