Typed, accessible, theme-aware React/Tailwind components installable by the shadcn CLI or your AI coding agent. Free atoms plus composed Pro blocks.
Text with the parts matching a search query highlighted, built so that neither the text nor the query can break it — and both of them routinely arrive from a URL query string. Reach for it wherever somebody has just typed something and needs to see where it landed: search results and their snippets, a filtered list or a table under an active filter, in-page and in-document find, a docs or knowledge-base search page, log and diff viewers, autocomplete and combobox options, a tag or user picker, admin record lookup, and the "showing 12 results for …" line above any of them. Common asks it answers: "highlight search term react", "highlight matching text react", "react-highlight-words alternative", "highlight-words-core", "react highlighter component", "shadcn highlight text", "highlight search results react", "mark tag react", "wrap matches in mark react", "highlight substring in string react", "highlight multiple words react", "case insensitive highlight react", "highlight text without dangerouslySetInnerHTML", "highlight search term xss", "escape regex special characters search", "new RegExp from user input invalid regular expression", "search query with special characters breaks", "highlight accent insensitive search", "remove diacritics search javascript", "normalize NFD strip combining marks", "highlight overlapping matches", "find in page highlight react", "scroll to current match react", "検索ワード ハイライト react", "該当箇所を光らせる", "検索結果 マーカー 表示", "全角 正規表現 エスケープ 検索". The reason to install one rather than write it is that the three-line version is a security bug. `text.replace(query, '<mark>' + query + '</mark>')` into dangerouslySetInnerHTML is what everybody writes first, and it injects HTML from two directions at once — the body text and the search term, which on a results page is usually `?q=` straight off the address bar. This never builds a string of HTML at all. It cuts the text into runs and React renders them, so a `<script>` in either input is characters on screen and nothing else. The query is never compiled to a regular expression either, which is the other half of the same problem and the one that shows up in the bug tracker rather than the security report. `C++`, `a.b`, `$100`, `(draft)` and `[WIP]` are all ordinary things to type into a search box and all of them are also regex syntax: `new RegExp(q)` throws `Invalid regular expression` on some and, worse, quietly matches the wrong text on others — search `a.b` and watch `axb` light up. The usual patch is an escaping helper copied from a gist. Here there is nothing to escape, because matching is `indexOf` over a folded copy of the string. That folded copy is where the real work is. Matching has to ignore case and accents — `café` should be found by `cafe` — but the highlight has to be drawn on the original text, and the two strings do not have the same length. Stripping a combining mark shortens it; `İ` lowercases to two characters and lengthens it; `Σ` and its word-final form `ς` are the same letter written two ways, so `ΕΛΛΑΣ` is invisible to anyone typing `ελλάς`. An offset found in one string and used in the other is wrong by a few characters, and the damage lands next to the highlight rather than in it — a duplicated or eaten letter somewhere along the line, where nobody is looking. So no offset is ever converted: the text is split into grapheme clusters with `Intl.Segmenter`, each is folded on its own, and every match is carried back through a recorded boundary. Concatenating the output always reproduces the input exactly, whatever the query. Working in graphemes is also what stops it tearing a character in half. A flag is two regional indicators, a thumbs-up with a skin tone is a base plus a modifier, and a `<mark>` drawn around the first half of either one splits it on screen — the flag falls apart into two letters, the modifier is orphaned beside the thumb. A match that covers only part of a character is passed over and the search carries on from the next position. Multiple terms are handled the way a reader expects rather than the way the loop falls out. A string query is split on whitespace, because that is what the search backend did with it and a phrase that never occurs verbatim would otherwise highlight nothing at all; pass an array to match exact phrases instead. Terms that overlap — `ab` and `bc` both land on `abc` — are merged into one highlight, since a `<mark>` cannot be nested inside another and emitting the shared letter twice corrupts the text. Matches that merely touch are deliberately left as two, so a find bar still counts what the reader can see. It is a real `<mark>` element rather than a styled span, which matters on somebody else's machine: in Windows high contrast mode the browser replaces author colours with system ones and knows to give a `<mark>` the system's own highlight pair, while a span painted to look identical is handed the ordinary page colours and every highlight on the page silently disappears for the readers who turned high contrast on in order to see things. The user-agent `color: black` that ships with `<mark>` is overridden so the text keeps the colour it already had instead of turning black in dark mode, and the highlight carries no horizontal padding, which would otherwise re-space the line as the reader types. The text itself is never cut down or rewritten, so screen readers, find-in-page, selection and copy all see exactly the string that was passed in. For a find bar with next and previous buttons, `activeIndex` marks one match as the current one: the marks carry `data-match-index`, the active one carries `data-active="true"` and `aria-current`, and bringing it into view is `container.querySelector('[data-active="true"]')?.scrollIntoView()`. `splitHighlight(text, query, options)` is exported on its own — a pure function from a string and a query to a list of runs — for counting matches, for highlighting into a canvas or a PDF, or for testing your own rendering. Official shadcn/ui has nothing for this, 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 served only on the new-york-v4 style track) and concatenating the component sources gives 211,787 bytes, in which `<mark`, `Highlight`, `escapeRegExp`, `Intl.Segmenter`, `normalize("NFD")`, `searchWords`, `caseSensitive` and `matchDiacritics` are every one of them zero hits. The two occurrences of the word are `data-highlighted`, the Radix menu-item state, which is a different thing entirely. Within pulld it is the display half of search: search-input is where the query is typed, command-palette highlights the fuzzy subsequence it matched inside its own option list, and this is the one you point at arbitrary body text. It composes with read-more and middle-truncate on the same line of a result, and sits naturally in a table cell, a tree-view label or a diff-view row. No dependencies, and no hooks — so it renders inside a React server component with no "use client" of its own and ships no client JavaScript, which is the common case, because search results have usually just been fetched on the server.
An inactivity timeout with a warning before it fires: it watches for the user going away, puts up a "you will be signed out in 1:59" dialog with a live countdown, lets them stay with one click, and keeps every open tab agreeing about when the session actually ends. Reach for it on any screen behind a session that expires on its own: banking, brokerage and payments, health records and patient portals, insurance and claims, payroll and HR, government and tax filing, admin consoles, CRM and EHR, anything under a PCI DSS, HIPAA, SOC 2 or internal policy that mandates an idle logout, and any internal tool where a shared or unattended machine is a real possibility. Common asks it answers: "react idle timer", "react-idle-timer alternative", "session timeout warning react", "auto logout after inactivity react", "inactivity timeout react hook", "detect user inactivity react", "useIdleTimeout", "warn user before session expires", "session expiry countdown dialog", "keep me signed in prompt", "next.js auto logout", "idle detection react", "cross tab session timeout", "BroadcastChannel session sync", "logout user after 15 minutes of inactivity", "shadcn session timeout dialog", "stay signed in modal", "useIdle hook", "useIdleTimer", "idle timer hook typescript", "auto logout react hook", "log out inactive users", "sign out after 15 minutes idle", "session timeout modal with countdown", "warn before auto logout", "idle timeout remix", "idle timeout vite react", "react router session timeout", "HIPAA automatic logoff react", "PCI DSS 15 minute inactivity timeout", "SOC 2 session timeout requirement", "timer stops running in background tab", "setTimeout throttled when tab is hidden", "countdown wrong after tab was in the background", "session expired while tab was inactive", "logout all tabs at once", "sign out other tabs react", "sync logout across tabs", "自動ログアウト react", "無操作 タイムアウト", "セッションタイムアウト 警告", "放置 ログアウト 実装", "タブ間 ログアウト 同期". The reason to install one rather than write a setInterval is that counting elapsed time is the wrong shape for this problem, and it fails in the exact situation the component exists for. A browser throttles a background tab's timers to once a minute or slower, so a counter that decrements per tick falls behind real time by however long the tab was hidden — and it falls behind in the dangerous direction, reading high. The dialog claims two minutes of grace when the session died ninety seconds ago, and the user clicks "stay signed in" on a session the server has already dropped. Everything here derives from one timestamp instead: when the person was last seen. Nothing is accumulated, so nothing can drift; the state is read off the clock at every wake, whenever the tab becomes visible again, on focus, and on a bfcache restore, which is what turns "the counter was frozen while you were away" into "the counter was right all along". Timers are also never armed for the whole wait, because a delay over 2**31-1 ms silently wraps and fires immediately — a session measured in days would sign the user out on page load — so the wake is capped and the deadline re-read, which covers the overflow, a laptop waking from sleep, and an NTP correction with one rule. The second thing a page-local timer cannot do is see the other tabs. Someone with the app open in three tabs is working in one of them; the other two observe no events at all and each will announce, on its own authority, that the session has expired — so whichever tab they return to has signed them out of work that was never idle. Activity is therefore broadcast, over BroadcastChannel where it exists and a localStorage write where it does not, since the storage event fires in the other tabs, which is exactly the audience. What travels is the moment a person was last seen rather than a computed deadline, because two tabs may be configured with different timeouts and the sighting is the fact; the merge rule is to take the later one, since a tab that saw activity saw a person while a tab that saw none is only reporting that nothing happened in front of it. Signing out is broadcast the same way, so one tab ending the session ends it everywhere. The broadcast is throttled: a message per mousemove would be a storm across every open tab, and being a few seconds stale about a timeout measured in minutes changes nothing. Passive activity deliberately does not dismiss the warning. Once the prompt is up, only a real answer puts the deadline back — a dialog that a mouse move clears cannot be read at all, because reaching for its button clears it, and the question the warning asks is whether a person is still there, which a trackpad brushed by a sleeve does not answer. Before the prompt appears, every configured event counts, listened for passively and in the capture phase so that a component calling stopPropagation on its own pointer events does not make its part of the page look deserted, and so that scrolling inside a pane — a scroll event does not bubble — still counts as being alive. What it does not claim to be is enforcement. Hidden-tab timers are throttled, a machine asleep through the deadline signs out when it wakes rather than on time, and any of it can be turned off from a console. The server's session lifetime is the security boundary; this is the courtesy that stops people losing work to it, and the two are meant to be kept in step. Official shadcn/ui has nothing in this area: across all sixty-three components, inactivity, auto-logout, session, BroadcastChannel, visibilitychange, localStorage, expiry, heartbeat and deadline are all zero hits — the three idle matches belong to attachment's upload state and the timeout matches to toast's own dismiss queue, neither of which is a session. Within pulld it is the counterpart to duration-input, which is the control for configuring an auto-logout window: the number that component produces is the timeoutMs this one counts down. It is distinct from countdown, which runs to a fixed public moment like a sale ending, where this deadline moves every time the user does something; from network-status, which also re-reads on visibilitychange but to re-probe a connection; and from unsaved-changes-guard, which interrupts a departure the user chose, where this one interrupts an absence nobody chose. The dialog is an alertdialog because it interrupts rather than being asked for, and focus lands on "stay signed in" rather than on "sign out now", so an Enter press already on its way to the page cannot end the session; Escape does the same as staying. The countdown is a role="timer" with live updates off, and a separate polite region carries the time at widening intervals — every minute, then thirty and ten seconds, then each of the final five — because a per-second announcement is a barrier rather than an aid, each one cutting off the last. useIdleTimeout() is exported for a dialog of your own, along with the small pure pieces it is built from, 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.
A before/after slider: two images stacked in the same box with a divider you drag across them, so the second is revealed over the first instead of sitting next to it. Reach for it wherever a page has to show the same frame twice and the point is the difference: a photo edit or retouch shown against the original, an AI upscale, denoise, colourise, restore or background-removal result next to its input, a generative fill or inpainting demo, a model-A-vs-model-B output pair, a renovation or remodel gallery, before-and-after in a portfolio or case study, a design revision against the version it replaced, satellite or map imagery of the same place at two dates, a scan or microscopy image with and without processing, graphics settings or shader quality in a game, a screenshot in light and dark theme, and image compression quality (original against the optimised file). Common asks it answers: "before after slider", "before and after image component", "image comparison slider", "compare two images react", "split image slider", "image reveal slider", "drag to compare photos", "photo comparison component", "before/after react component", "img-comparison-slider alternative", "react-compare-image alternative", "react-compare-slider alternative", "shadcn before after", "shadcn image comparison". Official shadcn/ui has nothing for this and no combination of its parts gets there: slider is a Radix range primitive that knows nothing about images, aspect-ratio only holds a box at a shape, and carousel shows pictures one after another rather than one over the other. Distinct from diff-view, which compares two pieces of text line by line — this one compares two pictures of the same thing. Pass `before` and `after` and that is the whole setup: `before` lays out in normal flow and gives the pair its height, `after` is overlaid and clipped, and `beforeLabel` / `afterLabel` pin captions to the corners that disappear when their side is closed. It is uncontrolled by default (`defaultPosition`) and controlled by passing `position` with `onPositionChange`, which fires on every drag frame and key press. The interaction is a real range input covering the whole picture rather than a mousedown/mousemove pair, which is where hand-rolled versions come apart. Dragging works from anywhere on the image, a click jumps the divider, pointer capture keeps the drag alive when the cursor leaves the box, and touch and pen work without a second code path — none of which a mouse-event implementation gets, and it cannot be operated from a keyboard at all. Here the arrow keys move the divider by one percent, Page Up and Page Down by ten, Home and End go to the ends, and each press snaps to the step's own grid so a keyboard user lands on whole numbers instead of inheriting the fraction a drag left behind. Three details it settles that are invisible until they are wrong. The thumb is one pixel wide, because a range maps the pointer onto the track minus the thumb, and a default 16px thumb leaves the drawn divider drifting up to eight pixels from the finger near the edges. `touch-action: pan-y` keeps a vertical swipe scrolling the page, so an image that spans a phone screen is not a trap you cannot scroll past. And the input is `dir="ltr"` whatever the page direction, because 0 has to mean the left edge — a divider is a place on a picture, not a position in a line of text. Accessibility is the pair, not just the control: clipping is visual, so both images stay in the accessibility tree and a screen reader reads both alt texts, while the divider is a labelled slider that announces its position as a percentage. The focus ring is drawn on the visible handle through the transparent input, so it is themed rather than a browser outline over a photograph. Styled entirely with shadcn tokens (background, border, foreground, ring), so it follows light and dark mode, and it ships zero dependencies — no Radix, no icon package, one file.
The step that comes straight after a photo is chosen: the picture sits behind a frame of the shape the product needs, and you drag it about and pinch or scroll to zoom until the right part is in the window. Reach for it wherever an upload has to end up a fixed shape — a profile photo or avatar, a team or workspace logo, a cover or banner image, an OG/social preview card, a product or listing photo, a thumbnail for a video or article, a group or channel icon, an ID or document photo being squared up before it is sent, a header image in a CMS, and the crop step of any onboarding or import wizard. Common asks it answers: "image crop react", "image cropper component", "avatar cropper", "profile picture crop react", "crop image before upload", "react-easy-crop alternative", "react-image-crop alternative", "cropperjs alternative", "shadcn image crop", "shadcn avatar upload", "crop to square react", "circular crop avatar", "zoom and pan image react", "pinch to zoom crop", "canvas drawImage crop", "get cropped image as blob", "cropped image is offset", "crop is off by a few pixels", "crop coordinates wrong scale", "image rotated after upload", "photo sideways after crop", "exif orientation canvas", "createImageBitmap imageOrientation", "canvas blank on iphone", "canvas too large ios safari", "resize image in browser before upload", "createObjectURL memory leak", "canvas toBlob securityerror", "tainted canvas crop", "accessible image cropper", "crop image keyboard only", "画像 クロップ react", "アバター 切り抜き", "アップロード 前に リサイズ", "写真が横向きになる exif". Official shadcn/ui has nothing to build this from, 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 212 KB of source, crop, naturalWidth, FileReader, createImageBitmap, drawImage, getContext, toDataURL, toBlob, imageOrientation, exif, devicePixelRatio, createObjectURL, setPointerCapture, pinch and the string <canvas are every one of them a zero hit. The only match for zoom anywhere is eleven occurrences of Tailwind's zoom-in-95 / zoom-out-95 overlay animation on dialogs and menus. Its aspect-ratio component is a six-line re-export of the Radix primitive that holds a box at a shape and knows nothing about a picture inside it, and avatar renders an image that has already been made square by somebody else. So an agent asked for a crop step builds it from a bare canvas, and the three things that make this hard are exactly the three it gets wrong. The first is that the coordinates on screen are not the coordinates being cut. A drag is measured in CSS pixels on a 320px-wide preview; the crop is taken from an 8000px original, and the factor between them has to be applied exactly once, in one direction. Applied the wrong way or against a frame width measured before a sidebar opened, the saved picture is offset from the one that was on screen — close enough to ship and wrong enough to notice. This keeps the value in the source image's own pixels ({ x, y, width, height }), so the crop survives a resize, a phone with a different screen and a round trip through a database, and derives the screen geometry from it rather than the other way round. The frame is measured with a ResizeObserver rather than once on mount, because a dialog or a sidebar changes its width without the window doing anything. The second is EXIF orientation, and it produces the bug report everybody recognises: the preview is the right way up and the saved avatar is lying on its side. A phone stores the photo in the sensor's orientation with a tag saying which way to turn it; browsers have applied that tag to <img> for years and drawImage of the same file has not always agreed, so the picture rotates somewhere between the box that was cropped and the canvas it was cut into. createImageBitmap(blob, { imageOrientation: "from-image" }) is the line that settles it, and here the result is checked against the preview before it is trusted — if the bitmap and the element disagree about which side is the long one, the element wins, because being faithful to what the user actually chose beats being upright. The third is that the output is otherwise unbounded. The obvious way to cut a rectangle out is a canvas the size of the source: for an 8000x6000 photo that is 48 megapixels, 192 MB of RGBA before anything is drawn, and the destination is a 256px avatar. iOS Safari does not throw there — it drops the backing store and hands back a blank image, or kills the tab. So the canvas is made at the size of the output and the crop is scaled into it in a single drawImage with high-quality smoothing, never allocating more than the picture actually wanted. Pass output={{ width: 512 }} for a slot you have to fill, or leave it and the longest side is capped at 2048; a crop is never upscaled, which would only make a bigger file out of the same detail. It is operable without a pointer, which is the part hand-rolled croppers skip and the reason a sign-up step becomes one a whole class of people cannot finish. The frame takes focus and answers the arrow keys (Shift for a longer step) and + / -, and under it are real range inputs — zoom, plus a horizontal and a vertical position that announce themselves as percentages — with the position pair sr-only until something in it has focus, so a screen reader always reaches them while a sighted keyboard user sees them appear only on tabbing in rather than facing two sliders that duplicate the arrow keys. Pointer capture keeps a drag alive past the edge of the frame, two-finger pinch zooms about the point between the fingers, a wheel zooms about the cursor through a non-passive listener (React's own wheel handler is passive, where preventDefault does nothing and the page scrolls behind the cropper), touch-action is none so the first drag on a phone does not scroll the page instead, and the browser's native image drag is turned off so a pan does not hand the file to whatever is underneath. The API: src takes a URL or the File/Blob straight from the input — pass the Blob when you have one, and the object URL is made and revoked for you, which is the leak every upload screen has. Then aspect, shape="round" to draw the frame as a circle (the export stays the rectangle: an avatar is displayed round by the page that shows it, and baking the circle in means a transparent PNG that grows black corners the first time something flattens it), maxZoom, grid, controls, disabled, crossOrigin, and labels for translation. value/defaultValue and onChange work controlled or uncontrolled over the crop rectangle, and onChangeEnd fires once a gesture settles — the one to re-encode a preview in, since onChange fires per pointer frame. The ref exposes getCrop, setCrop, reset, getImageSize, toCanvas, toBlob and toDataURL. coverCrop, clampCrop, cropZoom, zoomCropTo and outputSize are exported as pure functions, so the same rectangle can be re-cut on a server that has no DOM. There are deliberately no resize handles: free-form selection with eight grips is a different component doing a different job, and the frame here is the output shape. Within pulld it completes the upload path: file-dropzone takes the file in, this shapes it, upload-list shows the rows while it goes up. It sits beside image-comparison, which puts two pictures of the same thing under one divider, and signature-pad, the other component here that draws. One file, zero dependencies, not even an icon, and every colour is a shadcn token except the thirds guides, which are a translucent white on purpose because what they lie on is a photograph rather than a themed surface. Light and dark follow on their own.
A frame that zooms whatever is inside it — wheel, trackpad pinch, double click or the buttons — and lets you drag it around once it no longer fits. Reach for it wherever a picture is too detailed to read at the size it is shown: product and listing photography where the buyer wants the fabric, the stitching or the serial number, floor plans and architectural drawings, engineering and CAD schematics, circuit diagrams, maps and site plans shipped as images, satellite and aerial imagery, medical scans and microscopy, screenshots in documentation and bug reports, design and artwork review, scanned documents and receipts, org charts and flow diagrams, dense data visualisations, archive and museum photography, and any figure in an article that is legible on a desktop and unreadable on a phone. Common asks it answers: "image zoom react", "pan and zoom component", "pinch to zoom image", "zoom into image on scroll", "wheel zoom image react", "draggable zoomable image", "zoom and drag image", "react-zoom-pan-pinch alternative", "medium-zoom alternative", "panzoom alternative", "image viewer component", "zoomable diagram", "magnify image component", "shadcn image zoom", "shadcn pan zoom". Official shadcn/ui has nothing for this and no combination of its parts reaches it: aspect-ratio only holds a box at a shape, carousel moves between pictures rather than into one, dialog can present an image larger but cannot magnify part of it, and scroll-area scrolls content it never scales. Distinct from image-comparison, which slides between two pictures of the same thing, and from image-crop, which chooses a region to save — this one changes nothing and only alters how closely you are looking. Pairs with fullscreen-button, which is how the frame gets big enough for zooming to pay off: go full screen first, then zoom in. The component turns on one piece of arithmetic: whatever is under the pointer stays under the pointer. Applying scale() about the centre of the frame is the one-line version everybody writes, and it is useless on anything worth zooming — the detail you aimed at slides toward the edge as you go in, faster the further from the middle it started, so reading a label becomes a game of zoom-then-drag-it-back. Here the translation is re-solved for each new scale so the anchor holds, and the scale is clamped before the translation is solved rather than after, which is what stops the picture lurching sideways on every wheel notch past the maximum. Four more things it settles that are invisible until they are wrong. The wheel listener is registered by hand with passive: false, because React attaches onWheel passively in several browsers and a passive listener's preventDefault is silently ignored — the frame zooms while the page scrolls out from under it. A trackpad pinch arrives as a wheel event with ctrlKey set rather than as a touch gesture, so a component written only for touch does nothing at all on a laptop; both paths run through one handler here. Wheel deltas are read with deltaMode, because Chrome and Safari report pixels while Firefox reports lines — ignoring it makes the zoom feel right in one browser and roughly sixteen times too slow in the other. And the steps are exponential rather than linear, so scrolling in and back out lands on the scale you started from instead of creeping smaller all afternoon. Everything the pointer can do has a keyboard form, because none of these gestures have one on their own: the frame is a tab stop, + and - zoom about its centre, the arrow keys pan, and 0 resets. The arrows are left to the page when there is nothing to pan, and touch-action is pan-y while the content fits and none once it does not, so a full-width frame is never something you cannot scroll past on a phone. The controls are labelled buttons marked aria-disabled rather than disabled — a real disabled drops them out of the tab order the moment you reach a limit — and they stay silent instead of reporting a change that did not happen. The zoom level is shown as a percentage in a polite live region, so it is announced rather than left as a visual-only state. Pan is clamped against the content's own unzoomed size rather than the frame's, so a letterboxed picture cannot be dragged half out of the box, and the frame re-clamps when it is resized, which is what a phone rotated while zoomed in needs. Uncontrolled by default (defaultTransform) or controlled by passing transform with onTransformChange. The maths is exported too — zoomAt, clampPan, clampScale and contentPointAt — so an annotation layer can map a click back to a point on the unzoomed image without reimplementing any of it. Styled entirely with shadcn tokens (border, background, muted, ring, accent), so it follows light and dark mode, and it ships zero dependencies — no Radix, no icon package, one file.
The footer of a list that keeps going: an invisible sentinel that loads the next page as it scrolls into view, plus a Load more button that always does the same job by hand. Reach for it on a feed or timeline, search results, a notification or activity list, a product or photo grid, a comment thread, chat history, an audit log, or any 'show more' at the end of a long table. Common asks it answers: "infinite scroll", "infinite scrolling react", "load more on scroll", "load more button", "endless scroll", "auto load next page", "IntersectionObserver load more", "react-infinite-scroll-component alternative", "scroll pagination", "fetch next page when the sentinel is visible", "lazy load a long list". shadcn/ui's pagination is numbered page links and nothing else — it renders no rows and loads nothing — so progressive loading gets hand-rolled every time, and the same three things break. Here the page footer stays reachable, because automatic loading yields to the button after autoLoadLimit pages (default 3, and a press grants another run) instead of running the page away from whatever is below the list. Each page is announced through a polite live region ("20 more items loaded. 60 in total.") rather than rows appearing in silence, and the button uses aria-disabled instead of disabled so pressing it never drops focus out of the list. And a failed page stops the sentinel and offers Retry instead of hammering a broken endpoint in a loop. Return a promise from onLoadMore and the duplicate-fire guard is exact; a loader that only bumps a page number is held until the list actually changes, so it asks once instead of firing a burst. A first page shorter than the viewport keeps loading until the viewport is full — the usual bug there is a sentinel that never leaves the screen, so no second intersection event ever comes and the list stops loading forever. Controlled: pass hasMore, itemCount and onLoadMore, and render it directly after your rows — it draws no list of its own, so it goes at the end of a ul or a grid unchanged. A table is the one place it needs placing by hand: this renders a div, and the HTML parser hoists a div written inside tbody out of the table entirely and drops it above the table, breaking the layout and hydration with it — so put it after the closing table tag, or inside a td with colSpan in a footer row. Optional loading for react-query or SWR, error for your own failure state, root for a list that scrolls inside a box rather than the page, rootMargin (default 200px) to prefetch early, auto={false} for button-only, and labels to reword or translate every string. Styled with shadcn tokens so it follows light and dark themes; lucide-react is the only dependency, with no Radix and no scroll library.
Click-to-edit text that stays where it is: a value shown as plain text with a pencil affordance that swaps to an input in the same spot when activated, commits on Enter or blur, and reverts on Escape — no dialog, drawer or separate edit form. Reach for it wherever one field is edited far more often than the rest of the record: renaming a project, board, list, folder, file or column header, a kanban or issue card title, a document or dashboard name, a cell in a table or data grid, a display name, bio or label in profile and settings, an environment or API key nickname, a saved view or filter name, a playlist or collection title. Common asks it answers: "inline edit react", "click to edit text", "edit in place", "editable text component", "editable label", "rename inline", "inline rename react", "double-click to rename", "contenteditable alternative", "react-contenteditable alternative", "editable table cell react", "inline edit input shadcn", "shadcn editable text", "notion-style inline editing", "click to edit title". Official shadcn/ui has nothing in this area, and it is worth stating precisely: across all 63 of its components the words contentEditable, editable, dblclick, rename, select() and setSelectionRange do not appear once, and its input is a twenty-line bare <input> with no state of any kind. So what gets written inline is an Input plus a boolean, and the boolean is where the bugs are. The one nobody catches is focus. Enter and Escape unmount the input while it still holds focus, so focus falls to <body>, and the next Tab restarts at the top of the page — the user renames a card, presses Enter, presses Tab, and is somewhere in the site header. Here a keyboard exit hands focus back to the trigger it came from, while a blur exit deliberately does not, because focus has already gone where the user put it. The trigger is a real <button>, so it is in the tab order and opens on Enter or Space; a <div onClick> version, which is what gets hand-rolled, is invisible to the keyboard entirely. The input focuses and selects itself on entry so typing replaces the value rather than appending to it. onSave is called with the trimmed draft and only when it actually differs from the value — clicking in and back out fires nothing, which matters because in a real app that callback is a network request and a row in an audit log, once per stray click. Escape restores the original, and the draft is re-seeded from value on every entry, so reopening after a cancel does not show the abandoned text. An empty value renders muted placeholder text rather than a zero-width button nobody can find and click. Both halves are named for a screen reader — "Edit {label}" on the trigger and {label} on the input — because the visible text that named the field is exactly what disappears when it becomes an input. saveOnBlur (default true) is the switch between committing on blur and requiring an explicit Enter, which is the right choice when the save is expensive or destructive. What it deliberately is not: it is an <input>, so it is single line — reach for a textarea for a description — and onSave is fire-and-forget, with no pending or error state of its own, so wrap it if the save can fail. Within pulld it is distinct from copy-field, which is a read-only value beside a copy button, from floating-label-input and autosize-textarea, which are form fields that are always fields, and from slug-input, which transforms as you type. Controlled through value + onSave; disabled leaves the tab order; every colour is a shadcn token (input, accent, ring, muted-foreground) so it follows light and dark; one file, one lucide icon, no other dependency.
An install button for a web app that appears only when there is genuinely something to install, and knows the difference between an app that cannot be installed, one that can be installed but not by asking, and one somebody is already inside. Reach for it wherever the app is worth keeping rather than visiting: a documentation site or internal tool opened every day, a dashboard checked from a phone, a field-service, delivery, warehouse or point-of-sale app, a recipe, workout or practice timer, a reader, notes or journal app, a kiosk or presentation view, a game, a conference schedule, a ticket or boarding pass, and anything meant to keep working offline. Common asks it answers: "pwa install button react", "beforeinstallprompt react", "add to home screen react", "install prompt component", "useInstallPrompt hook", "a2hs react", "react-pwa-install alternative", "shadcn install button", "custom pwa install button", "beforeinstallprompt not firing", "beforeinstallprompt never fires in react", "install button does not appear", "deferredPrompt is null", "prompt() NotAllowedError pwa", "detect if pwa is installed", "display-mode standalone react", "navigator.standalone ios", "ios add to home screen instructions", "safari pwa install no api", "appinstalled event react", "PWA インストールボタン react", "ホーム画面に追加 react", "beforeinstallprompt 発火しない", "インストールボタンが出ない". Official shadcn/ui has nothing of the kind, and the figure is measured rather than remembered: its catalogue today lists 65 entries of which 62 are fetchable (code-block, questionnaire and trace 404) for 223,287 bytes of component source, and in all of it beforeinstallprompt, appinstalled, display-mode, standalone, navigator.standalone, manifest, pwa and even the bare words install and prompt are every one of them zero hits. So an agent asked for this writes it from scratch, and the version it writes has one bug in it that local testing will not show. beforeinstallprompt fires once, early, and never replays: Chromium dispatches it as soon as its own heuristics are satisfied, which on a warm cache is routinely before a React bundle has been parsed, let alone hydrated — so a listener added in useEffect is not late by a frame, it is late by the whole load. It passes in development, where the bundle arrives first, and fails in production, which is the single most common report about PWA install buttons: "it never shows up on my machine, but it does on yours". This component registers at module scope, moving the listener earlier by however long hydration takes, and exports earlyCaptureScript() — a few lines to inline in <head> ahead of the bundle — for the loads where nothing inside the bundle can be early enough, because the event has already fired by the time the bundle exists. The captured event is parked on window rather than in module state, so a page that somehow loads two copies of the file still shares one event instead of each copy believing there is nothing to install. Three more things separate it from the twenty-line version. Availability is four-valued — available, instructions, installed, unavailable — because "cannot install" and "can install, but not by asking" need different interfaces, and collapsing the second into the first is how an iPhone gets shown nothing at all on the platform where adding to the home screen matters most: iOS has no beforeinstallprompt at all, routes the whole thing through the share sheet, and so is given written steps instead — the same "design the missing environment as the real one" shape as share-button and fullscreen-button. Already-installed is read from matchMedia across all four installed display modes rather than standalone alone, because a manifest can ask for fullscreen or minimal-ui and an installed desktop app drawing into its own title bar reports window-controls-overlay — a check against standalone alone offers to install the app to somebody already running it — while iOS spells it navigator.standalone and has never implemented the media feature. And prompt() is honoured only during a user gesture and only once per event, so it is called as the first thing in the handler with nothing awaited in front of it: an await first, reading a flag or asking an API whether to offer this, is what turns a working button into NotAllowedError. The event is marked spent before anything is awaited, because a second click can land while userChoice is still unresolved. The first render is always the empty one, on the server and the client alike, so there is no hydration mismatch and nothing flashes in and back out again — reading matchMedia or the parked event during render instead is the same bug wearing a different hat. The component returns null whenever there is nothing to offer, so it can be left mounted where it belongs in the layout instead of being wrapped in a condition the page has to work out for itself. useInstallPrompt() is exported for a button of your own design, handing back status and promptToInstall(), with isRunningInstalled() and isIosLike() alongside it; onOutcome reports "accepted", "dismissed" or "unavailable" for analytics, and the manual steps are a prop, so they can be re-worded, translated or pointed at a different browser. It deliberately does not remember a refusal — whether somebody who said no yesterday should be asked today is a product decision with no safe default, and a shopping site and an internal tool want opposite answers — so record it through onOutcome, or compose it with announcement-bar's remembered dismissal. Within pulld it sits with the components that handle what the browser is doing behind the app: wake-lock-toggle holds a resource the browser can take back silently, network-status works out whether the connection is really there, geolocation-button handles a permission that can be refused for good, and this one handles the single capability the page cannot ask for twice. The instructions panel stays in the tree and is hidden with the attribute rather than left out, because aria-controls has to point at an element that exists; the toggle carries aria-expanded, both icons are aria-hidden, and the focus ring is focus-visible. Every colour is a shadcn token, so light and dark follow on their own, and its only dependency is lucide-react for the two icons.
A textarea for editing JSON by hand that says exactly where the mistake is — line, column and a caret you can jump to. Reach for it wherever a page has to let someone type a JSON value in: a setting or metadata field in an admin panel, a feature flag's payload, a webhook's test body, the request body in an API client or API playground, a job's input in a queue dashboard, a JSON/JSONB database column in an internal tool, an LLM tool-call's arguments, a connector or CI config form, a rules or mapping blob, seed data in a developer console. Common asks it answers: "json editor react", "json input component", "json textarea react", "edit json in a textarea", "react json editor shadcn", "shadcn json input", "validate json as you type", "json linter component", "show the line number of a JSON syntax error", "JSON.parse error position", "get line and column from SyntaxError", "highlight json syntax error react", "pretty print json button", "format json textarea", "tab key indent textarea react", "textarea tab inserts spaces", "jsoneditor react alternative", "react-json-editor-ajrm alternative", "codemirror json too heavy", "monaco editor just for json", "can't type a comma in my json input", "json input rewinds what I type", "controlled textarea resets while typing json", "cursor jumps to end json textarea", "react json form field". shadcn/ui has nothing for JSON: JSON.parse, JSON.stringify, SyntaxError, selectionStart, setSelectionRange and indentation each appear exactly zero times across the 223,287 bytes of component source its registry serves (62 of its 63 indexed items are actually served; questionnaire is listed but 404s on both style tracks), and its six mentions of textarea are the bare styled element itself, which knows nothing about what is typed into it. So this gets hand-written, and the hand-written version has one bug that makes the field unusable and one that makes it unhelpful. The first is the round trip. The obvious shape — parse the text, hand the object up, render JSON.stringify(object) back into the field — cannot be typed in, and not in a way a demo reveals: you have to reach a second key before it bites. The moment the text reads {"a": 1, it does not parse, there is no object to stringify, and whatever the parent hands back replaces what was being typed; even while it does parse, the round trip rewrites the text under the caret, so {"a": 1 becomes {"a":1} mid-keystroke and the comma can never be followed. Here the string is the value: `value` is text, parsing is a check that never touches it, and the parsed value is handed over as metadata through onParse. Keep the string in your own state — const [text, setText] = useState(…), not JSON.stringify(config) — and nothing fights the caret. The second is the error location, and the browser will not give it to you. No engine puts a position property on the SyntaxError that JSON.parse throws — the error carries message and stack and nothing else. The message is not an interface either: V8 writes "Expected ',' or ']' after array element in JSON at position 5 (line 1 column 6)" for some inputs and "Unexpected token ']', \"[1,2,]\" is not valid JSON" for others, with no position at all in the second form; Firefox writes "at line 1 column 6 of the JSON data"; JavaScriptCore writes "JSON Parse error: Expected ']'" and never includes a position. A component that scrapes the message therefore gets no location on Safari and no location on Chrome either for a whole class of mistakes — the trailing comma among them, which is the single most common thing wrong with hand-edited JSON. So this walks the RFC 8259 grammar itself, in one pass with an explicit stack rather than recursion so a deeply nested paste cannot overflow it, and reports the offset of the first offending character with a message written once and read the same way in every browser. JSON.parse stays the authority on whether the text is valid and the scanner is only ever asked where a failure is, so it can never reject a document the runtime would have accepted. Nothing is validated mid-keystroke: half-typed JSON is always broken, and a field that turns red on the second character is red the whole time it is in use, so the verdict waits for a pause (debounceMs, 200ms) and shows nothing in the meantime rather than a stale answer — which is also what keeps a few hundred kilobytes of payload from being re-parsed on every keypress. Formatting only ever happens when asked, through the Format button or formatOnBlur, never while typing, because reformatting replaces the whole value and throws the caret to the end of the document. Tab indents and Shift+Tab outdents every line the selection touches — and a selection that ends at the next line's first column stops short of it, so the closing brace you dragged past is not pushed over. A Tab that is always captured is a keyboard trap, so Escape releases it: Escape then Tab moves focus on, as WCAG 2.1.2 requires, and the release is announced in the field's own sr-only description instead of being left to be discovered. Escape is not swallowed, so it still reaches the dialog the editor sits in. The caret is restored after every indent through a layout effect rather than inside the key handler, because the value React commits replaces the whole contents of the textarea and a browser parks the caret at the end of a textarea whose value was replaced. Accessibility and form wiring: aria-invalid once the verdict is in and not before, the status line is aria-live="polite" and only ever updated after the pause, a caller's own aria-describedby is kept alongside the status and the hint rather than overwritten, the error message carries "Line 3, column 1" as text rather than relying on colour, and name submits the text like any textarea. autoCapitalize, autoCorrect, autoComplete and spellCheck default off, because a mobile keyboard rewrites a key name between the keypress and the change event and spellcheck underlines every identifier in the document; all four are overridable. parseJsonInput, scanJson and formatJson are exported as pure functions for the places the same answer is wanted outside React — a route handler checking what arrived, a CLI validating a config file, a test asserting on a fixture. Styled with shadcn tokens (input, ring, destructive, muted-foreground) for light and dark; className merges rather than fights; one file, zero dependencies, no Radix, no editor library, no JSON parser of its own. Distinct from json-viewer, which renders a parsed value as a read-only collapsible tree: that one is for showing a payload the reader did not write, this one is for changing it. Pair them when a panel both displays and edits. Distinct from code-block, which shows JSON as static text to copy, and from autosize-textarea, which grows to its content but knows nothing about what is in it. Distinct from official shadcn/ui's textarea, which is the plain field this is built on and nothing more: it accepts any string, has no idea whether it holds JSON, and reports no error, no line and no column — so validating what someone typed into it, finding where the mistake is, and getting a parsed value back out are all still yours to write.
Renders a parsed JSON value as a collapsible, type-coloured tree you can read and navigate. Reach for it wherever a page has to show a payload the reader did not write: an API or REST response in an admin or debug panel, a webhook body, the payload attached to a log or audit entry, a GraphQL response, an LLM tool-call's arguments, a JSONB/JSON database column, a feature-flag or config blob, a job's input and output in a queue dashboard, or the raw record behind a row in an internal tool. Common asks it answers: "json viewer", "json tree viewer", "json inspector", "object inspector", "display JSON in React", "render an API response", "pretty print JSON component", "collapsible JSON", "expandable JSON tree", "JSON formatter component", "view webhook payload", "debug panel for API responses", "react-json-view alternative", "shadcn JSON viewer". shadcn/ui ships nothing for JSON — its accordion and collapsible are single open/closed sections that know nothing about types, counts or depth — so this is normally either a raw <pre>{JSON.stringify(data, null, 2)}</pre>, which is unreadable past a screenful and cannot be collapsed, or a third-party viewer pulled in for one panel. Pass the value itself — `data={await res.json()}` — and nothing else is required: objects and arrays open and close, strings, numbers, booleans and null are coloured by type, every container reports how many entries it holds, and `defaultExpandedDepth` sets how much is open on first paint (0 for just the root, 1 by default, Infinity for the whole document). Built for payloads that are bigger than the demo: a container draws `maxItemsPerNode` entries (100 by default) behind a keyboard-reachable “… 39,900 more” row, so a 40,000-element array costs a hundred rows rather than mounting all of them, long strings are elided inside their quotes with the full text kept on hover, and a value that points back at one of its own ancestors is drawn as [Circular] instead of unfolding forever. It is the real ARIA tree pattern rather than a stack of collapsibles: role=tree/treeitem with aria-expanded, aria-level, aria-posinset and aria-setsize, and a roving tabindex that makes the whole viewer one Tab stop instead of one per row. Up/Down walk the rows actually on screen, Right opens a container and then steps into it, Left closes it or jumps to the parent, Home/End hit the ends, Enter/Space toggle a row or ask a “… more” row for its next page. `onSelect` hands back the row's accessor path — `$.items[0].id`, quoted so that `{ "a.b": 1 }` and `{ a: { b: 1 } }` never produce the same string, and pasteable straight into code — together with the live value, which is the hook for a copy button or a “filter to this” action; pair it with copy-button for copy-on-click. Open state is stored as the difference from `defaultExpandedDepth` rather than as a set of open paths, so swapping in the next response leaves the view opened to the same depth instead of collapsing to one unreadable root row. Counts are grouped without toLocaleString, whose locale-dependent output shows up as a hydration mismatch in Next.js. Styled with shadcn tokens (foreground, muted-foreground, accent, ring) plus dark-aware type colours so it follows light and dark themes; lucide-react is the only dependency, with no Radix, no state library and no JSON parser of its own. Distinct from tree-view, which renders a hierarchy you have already built into `{ id, label, children }` nodes: this one takes the raw parsed value and needs no node-building step, and it renders keys, types and counts rather than labels. Distinct from code-block, which shows JSON as static text to copy rather than a structure to open and walk.
Inline keyboard key rendered as a real <kbd> element and styled as a bordered monospace keycap — a drop-in for a bare <kbd> tag that otherwise renders as unstyled monospace text. Use it wherever an interface names a key the reader is meant to press: command palettes and ⌘K hints, tooltips, menu item accelerators, empty states that suggest a shortcut, onboarding tours, documentation, MDX and changelogs, release notes, and keyboard-shortcut help sheets. Common asks it answers: "kbd component", "keyboard shortcut badge", "render Cmd+K", "hotkey chip", "keycap style", "shortcut pill", "key badge", "style the kbd tag", "kbd tag looks like plain text", "kbd has no border", "style kbd with tailwind", "kbd in tailwind typography prose", "kbd styling mdx docs", "show a keybinding in a tooltip", "⇧⌘P badge", "shortcut hint next to a menu item", "Ctrl+S indicator", "Esc key label", "Enter key badge", "arrow key hint in docs", "show Cmd on Mac and Ctrl on Windows", "keyboard key component react", "shortcut key ui component", "ショートカットキー 表示 コンポーネント", "キーボードキー 見た目", "kbd タグ スタイル", "⌘K バッジ 表示". shadcn/ui now ships a kbd of its own, so choose deliberately rather than by search rank — and the difference is small enough to be worth stating exactly. Measured against their source today, theirs is 853 bytes and the strings border and font-mono are both a zero hit in it: it is a flat sans-serif chip at text-xs on bg-muted, with a min-w holding a square footprint and content centred, plus a KbdGroup wrapper for multi-key sequences, a rule that shrinks icons placed inside it, and one that inverts its colours inside a tooltip. If you want any of those, take theirs; the square footprint in particular suits single letters sitting in a row. This one is a single element with a visible border and monospace text at 10px on slightly wider padding, and it is padding-driven rather than min-width-driven, so a multi-character label like Esc, Tab, Enter or Space sits evenly inside the same border instead of being stretched to a square. The border and the monospace are the whole point: it reads as a physical key rather than as inline text, and it stays legible against surrounding prose at small sizes, which is where an unbordered chip at text-xs starts to look like emphasis rather than a key. Both are dependency-free, both use the semantic <kbd> tag so assistive technology announces the content as keyboard input, and both theme through shadcn tokens with no hardcoded colours. For several keys in a row, wrap them in a flex container with a gap yourself. Within pulld it is the piece the other shortcut components are built out of: keyboard-shortcuts composes it into a grouped help sheet opened with ?, command-palette is the ⌘K surface the hint usually points at, and shortcut-recorder is the other direction — capturing a chord the reader presses rather than displaying one you already know.
The help sheet that opens when the user presses ? — a modal listing every keyboard shortcut in the app, grouped by area, with the key caps drawn per platform. Use it as soon as an app has shortcuts worth discovering: an editor, inbox or mail client, issue tracker, admin dashboard, IDE-like tool, dev tool, chat or any keyboard-first product where power users expect ? to explain itself. Common asks it answers: "keyboard shortcuts dialog", "keyboard shortcuts modal react", "shortcuts help sheet", "keyboard shortcuts help modal", "press ? to see shortcuts", "question mark opens shortcuts", "shortcut cheat sheet", "hotkey list", "hotkeys help dialog", "keymap overlay", "key bindings list", "shortcut reference card", "keyboard shortcuts overlay", "GitHub/Gmail/Linear/Notion-style shortcuts help", "show all hotkeys", "⌘K help screen", "shadcn keyboard shortcuts", "nextjs shortcuts modal", "react hotkeys cheat sheet", and the libraries people reach for first and then have to build this on top of — "react-hotkeys-hook", "tinykeys", "mousetrap", "hotkeys-js", "cmdk" — none of which ships a help sheet. Also "show Cmd on Mac and Ctrl on Windows", "cross platform modifier key display", "render ⌘ or Ctrl automatically", "chord shortcuts g then p", and in Japanese "ショートカット一覧 モーダル", "キーボードショートカット ヘルプ", "ショートカットキー 一覧 react", "? でショートカット表示", "ショートカット チートシート". shadcn/ui ships nothing for this and its kbd is a bare key cap, so the sheet, the grouping, the ?-to-open wiring and the cross-platform key rendering are hand-rolled every time. Pass a `shortcuts` array of `{ keys, description, group? }`; groups render in the order they first occur, so the array is the outline. Write `"Mod"` in keys and it renders ⌘ on Apple platforms and Ctrl everywhere else — one source of truth instead of a Mac branch through your docs — and the literal token `"then"` renders as text rather than a cap so chords read as G then P. It documents shortcuts rather than binding them: your app already owns the handlers, and a component that registered them too would fight whatever hotkey library you use. The only key it owns is the one that opens it, and that listener ignores presses while focus is in an input, textarea, select or contenteditable, so typing "?" in a message box does not throw a modal over the composer. Platform detection runs in an effect, not during render — `navigator` does not exist on the server, so an inline branch would crash SSR or hydrate to different markup than it sent. Accessibility is the part that is easy to get wrong: the key caps are aria-hidden and each row carries an sr-only spoken form, because a screen reader meeting ⌘ announces "place of interest sign" or nothing at all, so the row reads "Open search, Command K"; opening moves focus into the dialog, which is what announces it, instead of a live region that would read the whole sheet twice; Tab is trapped, Escape closes, focus returns to whatever was focused before, and the scrolling list is itself focusable so a long list can be scrolled from the keyboard. Composes the kbd component for the caps. Styled with shadcn tokens (popover, muted-foreground, ring, border) for light and dark themes; lucide-react is the only dependency. Distinct from command-palette, which is a ⌘K launcher for running commands: this one is the reference card that tells users the shortcuts exist.
A UI language picker: every language named in its own language — 日本語, Deutsch, العربية, 한국어 — with its name in the page's language beside it, its BCP-47 tag, and a filter that reaches it by all four. Reach for it wherever a form has to settle which language something is read in: the interface or display language in account, profile, workspace and organisation settings; the language switcher on a multilingual site, marketing page or docs site; the language a customer's emails, notifications, invoices and receipts are sent in; the locale on an admin's view of a user, a seat or a tenant; the language of a help centre, knowledge base or support ticket; subtitle and audio-track selection; the source and target language on a translation or localisation screen; and the "preferred language" row on onboarding and sign-up. Common asks it answers: "language select", "language picker", "language switcher react", "locale select", "locale picker", "i18n language dropdown", "language selector shadcn", "shadcn language switcher", "BCP-47 select", "language combobox", "next-intl language switcher", "i18next language selector", "react-i18next dropdown", "choose interface language", "native language names select", "endonym language list", "list of languages react", "RTL language select". Official shadcn/ui has no language, locale or i18n item of any kind, and touches Intl nowhere in its sixty-three components — and the one item whose name comes close, direction, is a Radix DirectionProvider wrapper that takes a dir you have already worked out; it has no idea which languages are right-to-left. This fills that gap and exports the missing line between them. The fact the whole API is shaped around: there is no list of languages to read. Intl.supportedValuesOf answers for calendars, collations, currencies, numbering systems, time zones and units, and throws a RangeError for "language", "locale", "region" and "script" — unlike pulld's country-select and currency-select, which read the world's list at runtime. That absence is the right shape anyway, because a UI language field offers what you have actually translated, not the world's eight thousand languages: it offers the directories in your locales/ folder. So `languages` is the main prop — `languages={Object.keys(messages)}` — with a documented default of forty-one common tags so it renders something sensible on install. Every row leads with the endonym, the language naming itself, from Intl.DisplayNames(tag).of(tag). That is the one thing a hand-written language picker gets wrong, and it is not a nicety: the person opening this control is very often someone who cannot read the page it is on — that is why they opened it — so a list that says "Japanese" is no help to them and a list that says 日本語 is. The name in the page's language sits beside it for whoever is choosing on someone else's behalf, support staff setting a customer's locale or an admin filling in a seat, and disappears when it would only repeat the endonym. Tags, not bare ISO 639 codes, because a tag is what a locale directory is called: pt-BR and pt-PT are two shipped translations and the runtime names both ("Brazilian Portuguese", "European Portuguese"), zh-Hans is "Simplified Chinese", es-419 is "Latin American Spanish", and in Japanese the same call gives「ポルトガル語 (ブラジル)」. Right-to-left is line-drawn on purpose. Each row is marked with lang and dir, so a screen reader pronounces 한국어 with a Korean voice instead of spelling it out and العربية is laid out correctly inside a list that is not — but the component does not touch your document, because turning a chosen language into an RTL page is a decision about the whole tree. getLanguageDirection(tag) is exported so the two lines that do are yours (`document.documentElement.lang = tag; document.documentElement.dir = getLanguageDirection(tag)`), it is what you feed shadcn's own direction item, and it knows more than Arabic and Hebrew: Persian, Urdu, Pashto, Sorani Kurdish, Yiddish, Divehi, Sindhi and Uyghur are right-to-left too, while Kurmanji Kurdish and Azerbaijani are not. A tag the runtime cannot name is dropped rather than drawn raw — and the rule its sibling pickers use is not enough here, because Intl names "xx-US" as "xx (United States)", so the language subtag is checked as well; "pt_BR", the POSIX spelling that Intl rejects outright, and tags carrying a -u- extension go the same way. The filter reads four faces, because four different people type into it: the endonym (someone looking for their own language), the name in the page's language (someone choosing for them), the English name (typed constantly on non-English sites, because it is what the documentation says), and the tag itself (the developer, who has "pt-BR" in their head because it is the name of a directory). An exact tag wins outright, so "id" answers with Indonesian rather than Ido; a partial one finds a family, so "pt" brings both Portuguese translations and "zh" both Chinese scripts; folding strips accents and punctuation, so "cestina" finds čeština and 日本 finds 日本語; and a query that folds away to nothing, like "()", filters instead of quietly showing every row. Sorted through Intl.Collator by what the rows actually say, which groups the list by script and lets the reader's own collation decide where their script lands. The trigger is labelled with the endonym, and because that label does not depend on who is reading, it renders correctly on the server and never flashes a bare tag through hydration the way a name in the reader's language has to. A real combobox, not a styled div: the trigger is a type="button" with role="combobox" and aria-expanded, the panel is a listbox driven by aria-activedescendant, arrow keys, Home, End, Enter and Escape all work, the highlight scrolls itself into view, opening a field that already says 日本語 starts on 日本語, and an outside press closes it. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), and always emits the caller's own tag, character for character — never canonicalised, because that string is a key into your translations. `name` adds a hidden input so it submits with a native form; a stored tag outside a narrowed `languages` is still named rather than reading as "nothing chosen"; `priority` pins the two or three languages most of your readers use above the rest; `tags` turns off the tag column; and getLanguageName(), getLanguageEndonym() and getLanguageDirection() are exported for the rest of the page. Styled entirely with shadcn tokens (input, ring, accent, popover, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no locale-data package, no icon package, one file.
An <img> wrapper that holds its place in the layout from the first frame and defers its own fetch, for a page with more pictures than the screen: an article body, a product or portfolio grid, a long feed or timeline, a gallery or lightbox thumbnail strip, a list of avatars or logos, a comparison table of screenshots, a chat history with attachments, a blog index with hero thumbnails. Two things it gets right that a hand-written <img loading="lazy"> does not. First, the space: width and height attributes alone compute to aspect-ratio: auto W / H, and auto means the box re-shapes to the real file once it is known -- so a wrong declared size still shifts the page, just later (measured: a box declared 120x90 in a 300px column reserved 300x225 and then jumped to 300x300 when a 64x64 file landed). This emits the explicit aspect-ratio, which held at 300x225 through that same mismatch, and pairs it with object-fit so the picture is cropped rather than stretched into it. Second, the status: onLoad is not enough, because an image the browser had already finished with fires nothing for a handler attached later -- which is exactly hydration and a second visit to a cached URL, so the placeholder sticks on precisely the fastest images. It asks the element instead, and reads naturalWidth rather than complete, because complete is also true after a 404 and true for an element with no src at all. The fallback src is tried once and never retried, so a missing fallback cannot become the onError loop that hangs the tab. Common asks it answers: "lazy load images react", "react lazy image component", "shadcn image component", "shadcn lazy image", "next/image without next", "next image alternative", "image placeholder while loading react", "blurDataURL without next.js", "lqip react", "blur up image react", "image skeleton loader", "prevent layout shift images", "fix CLS images", "cumulative layout shift image react", "reserve space for image before it loads", "aspect ratio image placeholder", "image jumps when it loads", "page jumps as images load", "img onLoad not firing", "onLoad never fires for cached image", "image placeholder stuck on refresh", "react image onLoad hydration", "detect broken image react", "image fallback on error react", "onError infinite loop img", "fallback image react", "broken image placeholder", "img loading lazy react", "intersection observer image loading", "defer offscreen images", "lighthouse defer offscreen images", "lcp image fetchpriority", "eager load above the fold image", "aria-busy image", "decoding async img react", "lazy load below the fold", "image placeholder tailwind", "responsive image aspect ratio tailwind", "gallery thumbnails lazy load", "avatar list lazy load", "画像 遅延読み込み react", "画像 レイアウトシフト 防ぐ", "CLS 画像 対策", "画像 プレースホルダー react", "読み込み中 画像 スケルトン", "next/image を使わず遅延読み込み", "キャッシュ済み画像で onLoad が発火しない". Not the same thing as official shadcn's aspect-ratio, and not a replacement for it, and the gap is measurable rather than a matter of emphasis: that component is 274 bytes today -- a bare re-export of Radix's AspectRatio.Root with a data-slot on it -- and its skeleton is 267 bytes of div with animate-pulse. Neither knows anything about loading, caching or failure, and across all 223,287 bytes of official's registry (65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks) the strings lazy, naturalWidth, onLoad, onError, IntersectionObserver and fetchpriority are every one of them a zero hit. Official ships the box; nothing there watches the picture arrive. This is the image itself, and needs no wrapper element -- the reserved ratio, the skeleton and the low-resolution preview are all the img's own style, so it drops into the markup the old <img> was in and keeps every selector and grid placement that pointed at it. What it deliberately leaves alone: srcset and sizes (pass them through; picking them is the job of whatever produces your images), generating the tiny preview, and any CDN. Within pulld, image-zoom takes over once a picture is on screen and worth examining, image-comparison puts two side by side, and avatar-stack handles the small round case. useLazyImage is exported for an <img> of your own. Depends only on your cn util -- no packages, no other components.
A screen-reader announcement for something that happened without anything on screen moving: mount one `<LiveAnnouncer />` at the app root and call `announce("17 results")` from anywhere — an event handler, an async callback, a router subscription, a module that is not React at all. There is no provider and no context, so the call site does not have to be a component or sit under one. Reach for it wherever the page changed its mind and the reader has no way to know: a single-page-app route change (Next.js App Router, React Router, TanStack Router, Remix), the result count after a filter or a search-as-you-type ("17 results", "no matches"), a background save landing or failing, a row deleted or restored, a table re-sorted, an item moved in a drag-and-drop list, "20 more loaded" on an infinite scroll, a copy-to-clipboard confirmation, a cart or quantity update, a websocket or server-sent event arriving, a validation summary after a submit, an optimistic update being rolled back, a session about to expire, a file finishing upload, a step completing in a wizard, or anything wired to a mutation you already have — a TanStack Query mutation settling, a Next.js server action behind useActionState, react-hook-form's formState, tRPC, SWR, Convex, Supabase or Firebase. Common asks it answers: "react screen reader announcement", "announce to screen reader react", "aria-live react", "live region react component", "useAnnounce hook", "shadcn live region", "shadcn announcer", "visually hidden live region", "sr-only aria-live", "aria-live not working", "aria-live not announcing", "screen reader not reading aria-live", "aria-live region ignored", "aria-live only works once", "same message not announced twice", "aria-live duplicate text not read", "announce the same string again", "role status vs aria-live polite", "aria-atomic default value", "aria-relevant default", "polite vs assertive aria-live", "when to use aria-live assertive", "announce route change react", "screen reader route change announcement next.js", "react router announce page change", "spa page title announcement accessibility", "announce filter results count accessibility", "accessible toast announcement", "live region must be in the dom first", "aria-live inserted with content not announced", "NVDA not reading aria-live", "VoiceOver ignores aria-live", "スクリーンリーダー 読み上げ react", "aria-live 読み上げられない", "同じ文字列が二回読み上げられない", "ルート遷移 読み上げ", "polite assertive 違い", "読み上げ専用の領域 react". **Four things about live regions decide the shape of this component, and each one is a way the six-line version silently says nothing at all.** **A region has to be in the accessibility tree before its text arrives.** Rendering `<div aria-live="polite">{message}</div>` only while there is a message inserts the region and its content in one mutation, which is reliably not announced — the single most common reason a live region "does not work". So the regions here are always mounted and always empty until there is something to say, and a call that arrives before the announcer has mounted is held and written on a later task rather than rendered into the announcer's own first commit. **The same string written twice in a row is not read twice.** The region's content did not change, so there is nothing to announce — which is exactly why a "Copied" announcement works once and then goes quiet. The usual patch is to append an invisible character so the text differs, which trades one bug for two: the reader may read the padding, and the announcement is no longer the message. Instead each politeness owns *two* regions and messages alternate between them, so the receiving region always goes from empty to the message. Emptying the other one says nothing, because `aria-relevant` defaults to `additions text` and a removal is not in that set. **`polite` and `assertive` need separate regions.** The politeness is taken when the region is registered, not when its text changes, so one region per app whose `aria-live` is swapped per message is unreliable. These are four static regions: two polite, two assertive, and no message can change another's politeness. **`role="status"` is not the same region as `aria-live="polite"`.** The role carries an implicit `aria-atomic="true"` — present the region as a whole — while a bare `aria-live` leaves `aria-atomic` at its default of `false`, present only the part that changed. `role="alert"` likewise implies `assertive` and atomic. Each region here writes out its role, its politeness and its atomicity, so nothing depends on which implication a given reader implements. Two further decisions, both deliberate. A burst collapses: two messages of one politeness in a single task leave only the last, because a region holds one value and a reader polling after both writes would only ever have seen the second — "Loading…" immediately followed by "17 results" should say the latter, not both. And the queue holds one message per politeness rather than a list, which is the same decision seen from the server: a module that lives as long as the process and never mounts an announcer to drain it would otherwise accumulate a queue forever. Messages are emptied from their region after `clearAfter` (7 seconds by default, `0` to keep them) so a reader walking the page later does not meet a stale sentence; the removal is silent, as above. The timer is per politeness, not shared — a polite message expiring must not take an assertive one that arrived a moment later down with it. The regions are hidden with `sr-only`, which clips them while leaving them in the accessibility tree, and never with `hidden`, `display: none`, `visibility: hidden` or `aria-hidden`, any of which means nothing written here is ever read. For route changes, which are usually the first reason anyone reaches for this: announce the new title **or** move focus to the new page's heading, not both — moving focus already announces the page, and doing both reads it twice. `announce`, `clearAnnouncements` and `useAnnounce` are the API; `writeChannel`, `clearChannel`, `channelHasText`, `EMPTY_CHANNEL` and `DEFAULT_CLEAR_AFTER_MS` are exported as plain values so the alternation can be tested or reused without rendering anything. Official shadcn/ui has nothing of the kind, measured today rather than remembered: fetching every entry in its registry — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `aria-live`, `ariaLive`, `announce`, `politeness`, `assertive`, `aria-atomic` and `LiveRegion` are every one of them a zero hit. `role="status"` returns a single hit, and it is worth naming rather than rounding to zero: it sits on the `Spinner` component's SVG beside a fixed `aria-label="Loading"` — a label on a decorative element, not a region whose text ever changes. Official has no way to say anything. Within pulld the hole is not that nobody announces; it is that everybody rebuilds it. 46 of the other 135 components carry their own `aria-live` and 23 their own `role="status"`, each for its own one sentence: `save-status` announces its own saving-and-saved wording, `toast` carries regions for its own toasts, `form-error-summary` moves focus instead. This is the one you call when what happened is not any component's to report. No dependencies beyond React and your `cn`.
Submit button that shows a spinner, announces itself, and refuses to fire twice while an async action is in flight. Set loading={true} for the duration of the request and the button disables itself, so a second click cannot send the same request again; loadingText swaps the label for "Saving…" or "Charging card…" while it waits. It is the button for every action that has to go somewhere and come back: submit, save, publish, send, invite, apply, sign in, sign up, pay or check out, upload, export, retry, connect, delete in a confirmation dialog, and "generate" or "run" in an AI app. In practice the loading prop is wired straight to a state your data layer already has: isSubmitting from react-hook-form's formState, isPending from a TanStack Query useMutation or from React 19's useActionState, pending from useFormStatus inside a Next.js server action form, state !== "idle" from Remix or React Router's useNavigation, isMutating from SWR, isLoading from an RTK Query trigger — or a plain useState flag around an await fetch. Common asks it answers: "react button with loading spinner", "shadcn loading button", "button loading state react", "disable button while submitting", "prevent double submit react", "prevent duplicate form submission", "async submit button", "pending state button shadcn", "spinner inside button tailwind", "button spinner while awaiting fetch", "form submit loading state", "isPending button", "useFormStatus pending button", "useActionState loading button", "react-hook-form isSubmitting button", "tanstack query mutation loading button", "next.js server action loading button", "button aria-busy". Official shadcn/ui has no loading state anywhere in this path: its button ships no loading, pending or busy prop, and its spinner is a bare spinning icon with an aria-label — pairing them, disabling the button, and keeping the two in step is left to you, and doing it by hand is exactly where the double-submit bug comes from. This one carries aria-busy while pending, and the spinner it composes is pulld's, which puts the label in a role="status" polite live region rather than only on the icon, so the wait is announced instead of being a silent frozen button — and the label it announces is your loadingText, so "Charging card…" is what gets read out rather than a generic "Loading". Two things worth knowing before you drop it in a form: it defaults to type="button", so pass type="submit" explicitly when it submits a form (props are spread last, so your type wins), and disabling a focused button takes it out of the tab order — the live region is what carries the state to a screen reader once focus has moved. Within pulld it is the plain async button; confirm-button is the one that makes a destructive action be clicked twice, and type-to-confirm the one that makes it be typed out. The spinner atom installs with it through registryDependencies, it renders no client-side state of its own so it needs no "use client", and there is nothing else to add.
Puts a row of formatting buttons over a plain `<textarea>` — bold, italic, inline code and fenced blocks, links, bulleted and numbered lists, quotes — and writes the markers into the text without taking away the browser's own undo. Reach for it above any box somebody types prose into: a comment field, an issue or bug-report form, a pull-request description, a review box, a chat composer, a changelog or release-notes field, a README editor in an admin page, a support-ticket reply, a CMS body field, a notes or docs panel. Common asks it answers: "markdown toolbar react", "markdown editor toolbar shadcn", "textarea formatting buttons", "bold button for textarea", "wrap selected text in asterisks react", "insert markdown at cursor react", "textarea selectionStart selectionEnd react", "setRangeText react", "document.execCommand insertText react", "preserve undo after programmatic textarea edit", "cmd+z stops working after setRangeText", "textarea undo history broken", "markdown shortcuts cmd+b textarea", "keyboard shortcut bold textarea react", "toggle markdown bold off", "unwrap markdown markers", "prefix every selected line react", "bullet list button textarea", "renumber ordered list markdown", "blockquote button react", "insert link markdown caret in url", "toolbar button steals focus from textarea", "button mousedown preventDefault keep selection", "markdown ツールバー react", "textarea 選択範囲 装飾", "太字ボタン react", "undo が効かなくなる textarea", "markdown editor react no dependencies", "lightweight markdown editor react", "simplemde alternative", "easymde alternative", "@uiw/react-md-editor alternative", "react-markdown-editor-lite alternative", "toast ui editor alternative", "tiptap alternative for markdown", "lexical vs markdown textarea", "rich text editor without contenteditable", "github style comment box react", "add formatting buttons to a comment box", "shadcn textarea with toolbar", "markdown toolbar for react-hook-form textarea", "markdown composer next.js", "軽量 マークダウンエディタ react", "コメント欄 装飾ボタン", "textarea に太字ボタンを付けたい". **The undo stack is the whole engineering problem, and the obvious modern call is the one that breaks it.** `textarea.setRangeText` writes the text and records no undo entry: measured in Chrome 152, a `setRangeText` edit followed by `execCommand("undo")` came back exactly as the edit had left it — and that `undo` *returned `true`*, so the failure is invisible from the return value. The same edit through the deprecated `document.execCommand("insertText")` was reverted, in every focus arrangement tried, with two consecutive writes landing as two separate undo steps and a multi-line rewrite landing as one. So `insertText` is tried first behind a `queryCommandSupported` check and `setRangeText` is the fallback for the day it is removed, which is an uncomfortable thing to ship and still the only arrangement in which Cmd+Z after pressing Bold does what everybody expects. The fallback dispatches its own `input` event, because a controlled React textarea learns about a change from exactly that event and `setRangeText` fires none — without it the edit lands and the next render snaps it back. One edit is one range replacement, which is what keeps it to one undo step. **The focus guard is real and the usual reason given for it is not.** Pressing a toolbar button does not cost you the selection: measured through a real click on an unguarded button, `selectionStart`/`selectionEnd` still read 6 and 11 over the same word at mousedown, at click and after the write, because a textarea's selection belongs to the field and survives it losing focus. What the `mousedown` guard removes is a focus round trip — the unguarded click fired `blur` once and `focus` twice on the textarea, the guarded one fired neither — and those events are load-bearing in real forms, where save-the-draft and run-validation and close-the-composer all hang off blur. **Line markers and surrounding markers are different operations, and the mixed case is where implementations feel broken.** Bold surrounds the selection; a bullet goes at the head of every line the selection touches. Select four lines of which one is already a bullet, and a per-line toggle gives back three bullets and one plain line — so the marker comes off only when every line already carries it, and otherwise it is brought to all of them: one press to normalise, a second to clear. Blank lines are left alone and excluded from that vote. Numbers are rewritten to their position rather than repeated, so a block numbered 3, 7, 7 comes back 1, 2, 3. A selection ending at column 0 — one line of visible highlight, dragged down and released at the next line's left edge — does not reach the line it merely touched. **Toggling off is tested in both of the arrangements markers can be in, which is what stops it doubling them.** After a wrap the highlight stays on the words the person highlighted rather than on the markers, so a second press sees the markers outside the selection, while somebody who dragged across `**bravo**` by hand has them inside it; both are "this is already bold". The run of markers on each side is tokenised and the pairing has to balance, so the bold button finds its own markers through a layer of italics — pressing bold on `**_bravo_**` gives `_bravo_` and not `**_**bravo**_**`, which is what a check that only looks at the characters touching the selection produces. That requirement is also what stops it firing on `**bold**text`, where `**` touches the selection on the left and nothing closes it on the right. An empty selection inserts the pair and parks the caret between them; for a link the caret goes to whichever slot is still empty, so a selected URL becomes the target with the caret waiting in the label, and no placeholder word is left behind to be mistaken for a real one. One tab stop for the whole row with the arrow keys moving inside it, Home and End to the ends, and the arrows following the text on an RTL page; `role="toolbar"` with a label, every control a real `type="button"` so a toolbar inside a form cannot submit it, `aria-keyshortcuts` on the buttons that have a chord, Mod+B / Mod+I / Mod+E / Mod+K bound on the field where the hands are and accepting either modifier, and decorative icons `aria-hidden`. Theme tokens throughout with no hardcoded colours, one lucide icon set, and nothing else added. **It is not a markdown editor.** It writes markers into plain text and never parses: no preview, no syntax highlighting, no AST, and the toggles read the characters around the selection rather than a document model, so nesting a real parser would resolve is left to the person. It renders only the toolbar and the textarea stays yours — your rows, your placeholder, your validation, this registry's autosize-textarea if you want one. 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 execCommand, setRangeText, selectionStart, selectionEnd, insertText, markdown, toolbar, italic and blockquote are every one of them a zero hit; its `textarea` is a six-occurrence style wrapper and all six hits for "bold" are `font-semibold` in a title class. Official never touches a field's selection. Within pulld it sits beside selection-toolbar, which floats over text somebody is reading, and mention-input, which completes a word somebody is typing; this is the only component here that rewrites the selection in a field. Against the editor libraries rather than against official: tiptap, Lexical, SimpleMDE and the `@uiw` editors replace the field with a contenteditable surface and own the document model, which buys WYSIWYG and costs the native textarea — its undo stack, its spellcheck, its mobile keyboard behaviour, form submission, and a bundle measured in tens of kilobytes. This keeps the `<textarea>` and only writes into it, so what the user types is still markdown and what the form posts is still a string. Reach for a real editor when you need rendered output while typing; reach for this when the field is already a textarea and the text is already markdown.
A row of content that runs past without a seam, for the places where a list is longer than the space given to it and the point is the list rather than any one entry. Reach for it for a wall of customer or partner logos, a strip of press mentions, "as seen in" or integration badges, the speaker line-up on a conference page, a status or incident band along the top of a dashboard, a notice scrolling across a lobby or kiosk display, a ticker of recent signups, reviews or trades, a tag cloud that will not fit, or a screenshot reel on a landing page. Common asks it answers: "marquee react", "infinite scrolling logos react", "seamless infinite scroll carousel", "logo cloud auto scroll", "css marquee without jank", "ticker tape component react", "news ticker react", "auto scrolling text banner", "continuous horizontal scroll animation", "shadcn marquee", "logo marquee tailwind", "scrolling partner logos", "infinite loop animation react", "magicui marquee", "react-fast-marquee", "react-marquee-slider", "marquee pause on hover", "marquee accessibility", "prefers-reduced-motion marquee", "how many copies for a seamless marquee", "marquee jumps at the loop", "marquee speed depends on how many items", "my marquee duplicates screen reader output", "duplicate links in marquee tab order", "WCAG pause stop hide marquee", "横スクロールする帯 react", "ロゴを無限に流す", "繋ぎ目のないマーキー", "ニュースティッカー 実装", "ホバーで止まる流れる帯", "流れる帯 アクセシビリティ". **The seam is the whole problem, and the only way to hide it is to duplicate the content.** One copy sliding left eventually drags its own end into view, so the content is rendered several times over and the track travels exactly the distance from one copy to the next — at which point every copy stands where the one before it stood and the jump back to the start has nothing to give it away. Measured on a Web Animations loop, the computed transform at the end of an iteration is identical to the transform at the start. **The duplicates are `aria-hidden` and `inert`, and both are needed.** Measured in a browser, `aria-hidden` on its own leaves a clone's links and buttons focusable, so a wall of three logo links rendered in four copies hands a keyboard user twelve tab stops, nine of which lead somewhere a screen reader has been told is not there. `inert` takes them out of the tab order as well, and keeps a clone from swallowing a click meant for the real thing. Only the first copy is in the accessibility tree, so the list is read once. **Speed is pixels per second, not a duration**, because a duration is not a speed: three logos and thirty logos both set to "20s" travel at a tenth of each other's pace, so every addition to the list silently retunes the animation. Here the duration is computed from the distance that was measured, and adding a logo does not change how fast the band reads. **How many copies it takes is arithmetic, and the term everybody drops is the gap.** A loop moves the track by one step, so the copies have to reach `step + box width`; `n` copies reach `n * step - gap`, not `n * step`, because the gap trails every copy but the last. That makes the count `ceil((box + gap) / step) + 1`. The version without the gap is right almost everywhere — swept over 27 combinations of gap and box width it was short in one, and in that one (a 40px gap, a 300px box, a 316.44px step) it left 23.56px of emptiness at the trailing edge at the moment of fullest travel, once per loop, forever. The gap is never asked for: it is the measured step minus the measured copy width, so a gap set from anywhere still agrees with the layout. **The distance is read as a difference between two rectangles, never from `offsetLeft` or `offsetWidth`.** Those are rounded integers, and taking the step from them came out 0.5px and 0.4375px wrong in the two layouts measured — half a pixel of jump at the seam on every loop, for nothing. A difference also includes the gap without asking about it, and is immune to the transform the track is already under: the same two copies measured 268.4375 apart both at rest and mid-animation, while the absolute left of one of them had moved from 0 to −109.56. **The track is out of flow, with one hidden copy left in flow to size the box, and that is not a stylistic choice.** The track is as wide as the copy count, so a box taking its width from its content gets wider every time the count goes up, and a wider box needs a higher count: measured inside an `inline-flex` parent that ran 210px → 444px → 912px → 1848px as the count doubled, with nothing to stop it. With the track absolutely positioned and one copy's worth of content in flow behind it, the width came out identical at 1, 2, 4, 8 and 16 copies in all four parent layouts tried. The same hidden copy gives the box its height, so the band is the right height in the server's HTML rather than after a measurement — and it is intrinsically sized, because a spacer narrow enough to squeeze its items wraps them and reported 60px of height where the copies were 24px. **The box clips without becoming something that can be scrolled.** `overflow: hidden` makes a scroll container, and a browser scrolls one to reveal a focused descendant — measured, tabbing to the last link of the visible copy in a box narrower than that copy scrolled the box to 284 and left it there, sliding the whole band sideways for good while the animation carried on from the wrong place. `overflow: clip` clips identically without the scroll container: the same focus left it at 0 and did not scroll the page either. The class stays underneath as the fallback for an engine that drops the declaration. **Under `prefers-reduced-motion: reduce` this is a different component, not a stopped one.** A stopped marquee is a list with its end cut off: measured on a 300px box, 233px of the content sat outside it. Reduce gets a plain wrapping list instead, which measured 0px of overflow with every item inside the box. The fallback has to be a different tree rather than a flag because of the layout — the copies must refuse to shrink for the track to be wider than the box, and refusing to shrink is exactly what stops them wrapping. The media query is subscribed to, so turning the setting on mid-visit stops the band without a reload. **Hover and focus stop it, and the button is the part that matters.** Moving content that runs for more than five seconds needs a way to stop it (WCAG 2.2.2), and hover is not a way for anyone without a mouse, so the pause button is on by default, reachable by keyboard whether or not it is visible, and visible from the start on a device with no hover to reveal it with. Hover is ignored for touch pointers on purpose: a tap fires pointerenter with no pointer left to leave, and a band stopped for the rest of a visit because a thumb brushed a logo is worse than one that never stopped. Pausing holds the content where it is and resuming carries on from there rather than from the top — measured resuming at 806ms from a pause taken at 700ms — and anything that changes the distance (a resize, a web font arriving, a logo added) rebuilds the animation at the phase the old one had reached, because a rebuilt animation otherwise starts at zero and snaps the band back: measured, that put the track at translateX(0) where it had been at −134.219, while carrying the phase across reproduced −134.219 exactly. It animates through the Web Animations API rather than CSS keyframes, because the distance is a measurement and keyframes carrying a measured distance would have to be generated per instance — which a registry component cannot do to someone else's stylesheet. The animation is declarative either way, so this is not a `requestAnimationFrame` loop. `data-state` is "running" or "paused" for styling the stopped state, `paused` and `onPausedChange` hand that state to the caller, `direction` runs it rightwards, and `marqueeCopyCount` is exported so the arithmetic can be reused or checked. Content is cloned, so a child that holds state or costs something to render is rendered several times over; logos, names and short text are the intended cargo. Theme tokens only, no hardcoded colours, no dependencies beyond React and lucide-react for the two icons. **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 marquee, ticker, scrollAmount, prefers-reduced-motion, keyframes, animation and playbackRate are every one of them a zero hit. Official transitions between states 47 times and animates nothing at all: its single `translateX` is the progress bar holding its indicator at a fixed offset. Within pulld the nearest neighbours are tab-scroller and scroll-shadow, which both react to scrolling rather than moving by themselves, and announcement-bar, which is a band that holds still; sortable-list and swipe-actions move under a pointer rather than on their own. Nothing else in the registry runs continuously, and nothing else duplicates its children.
Holds a text field to one fixed shape while it is typed — a postal or ZIP code, a product or licence key, a serial, model or part number, an employee, member, account or policy number, a VIN, an IBAN, a SWIFT/BIC, a tracking number, an ISBN, a tax or national ID, a MAC address, a coupon or voucher code. You give it a mask: # is a digit, A a letter, * either, \ escapes the next character, and everything else is a separator the field writes for the person instead of asking them to type it. Common asks it answers: 'react input mask', 'masked input react', 'input mask shadcn', 'format input as you type react', 'react-input-mask alternative', 'cleave.js react', 'imask react', 'cursor jumps to end react input', 'caret jumps to end when formatting input', 'input formatting moves the cursor', 'react controlled input cursor position', 'cannot edit middle of formatted input', 'paste into masked input duplicates the dashes', 'backspace does nothing in masked input', 'strip formatting before submit react', 'store unformatted value react input', 'postal code input react', 'zip code mask', 'product key input component', 'license key input four groups', 'serial number input mask', 'VIN input react', 'IBAN input formatting', 'uppercase input react', 'numeric keyboard on mobile for code field'. shadcn/ui has nothing here at all: maskInput, inputMask, formatMask, placeholderChar, setSelectionRange and selectionStart each appear exactly zero times across the 223KB of component source its registry serves today (62 of its 63 indexed items are actually served; questionnaire is listed but 404s on both style tracks), and its single mention of a caret is input-otp's animate-caret-blink, a decorative blinking div rather than a caret position. So this gets hand-written every time, and the hand-written version has the same three bugs. The caret is the first and the worst: reformatting on every keystroke replaces the whole value, so the caret goes to the end — which nobody notices while typing a fresh code left to right, and which makes the field unusable the moment someone goes back to fix the third character, because every keypress throws them to the end again. It is tracked here in typed characters rather than string offsets, since the separators move underneath it — typing the fourth digit of ###-#### inserts two characters where one was typed. Backspace and Delete are intercepted for the same reason: left to the browser, backspacing over a separator deletes it, the reformat puts it straight back, and the key looks broken, so those keys remove a typed character and let the separator follow. Pasting is the second: a field that strips separators and re-inserts them cannot tell one the person pasted from one it is about to add, and produces 123--4567. Here there is no paste handler at all — pasted text, typed text and a value handed over by a parent are read through the mask by one walk, so a separator the text already carries is consumed by the slot that was going to write one, and a character that does not fit its slot is dropped rather than slid sideways into the next slot that would take it, because silently reordering what someone typed is worse than ignoring a keystroke they can see did nothing. What gets submitted is the third: the field shows 123-4567 and reports 1234567, with the formatted string beside it rather than instead of it, so a controlled parent that stores what it is handed stores the value and not the presentation. name submits the raw value, formattedName submits the formatted one when the separators are part of what you store. Not the component for a format another pulld field already owns — date-input, time-input, phone-input, currency-input and otp-input each carry rules a mask cannot express (a month that stops at twelve, a calling code that decides how many digits follow it, a decimal separator that moves with the locale, a box per digit), so use those for dates, times, phone numbers, money and one-time codes, and this one for the shapes that are only a shape. It also makes no claim about whether the contents are real: a mask is a shape and not a checksum — an IBAN has mod-97, a VIN has a check digit, a card has Luhn — so isMaskComplete reports that every slot is filled and stops there, and the field never turns red on the second keystroke. The rest of the API: tokens adds or replaces the slot characters, which is how a VIN excludes I, O and Q that no general table knows about (tokens={{ ...MASK_TOKENS, V: /[A-HJ-NPR-Z0-9]/ }}); transform folds to uppercase or lowercase for keys, VINs and IBANs; onValueChange reports the raw value plus { formatted, complete } on every keystroke; a mask that changes — a postal code whose shape follows the country above it — takes the value with it and tells the parent. Accessibility is where a mask usually fails quietly: ___-____ in a placeholder is read out as underscores or skipped entirely, so describeMask says the shape in words ("3 digits, then 4 digits") into an sr-only description, and a caller's own aria-describedby is kept alongside it rather than overwritten; pass hint for another language or null to drop it. inputMode is derived, so an all-digit mask opens the number pad on a phone and a mixed one does not; type is text, so a leading zero survives and the separators can exist at all; autoCapitalize, autoCorrect, autoComplete and spellCheck default off, because a mobile keyboard rewrites a serial number into a word between the keypress and the change event, and all four can be overridden by a field that does have a browser entry. Nothing rendered depends on feature detection, so the server and the first client render agree and there is no hydration mismatch. formatWithMask, unmask, isMaskComplete and describeMask are exported as pure functions for the places the same answer is needed outside React — a confirmation screen, an admin table, a route handler checking what arrived, a migration normalising a column written both ways. Styled with shadcn tokens (input, ring, muted-foreground) for light and dark, className merges rather than fights, focus ring is focus-visible. One file, zero dependencies.
The playback position bar for an <audio> or <video> element you already have — the filled progress, the buffered-ahead shading, a draggable thumb, keyboard seeking, and a screen-reader announcement that says "1 minute 12 seconds of 5 minutes 0 seconds" instead of a bare number. It attaches to your own media element by ref rather than rendering one, so it stays a position bar and leaves the transport buttons, layout and sources to you. Reach for it wherever media has a timeline: a custom audio player, a podcast or audiobook player, a voice-note or voice-memo field, a video player or preview, a lecture, webinar or course-recording player, a meeting or call recording, a screen-recording review, a security-camera or dashcam playback view, a music or sample preview, a transcript synced to audio, a video trimming or clip-selection UI, and the playback half of a recorder (it pairs with audio-recorder in this registry, which records but hands playback to the browser's own control). It is built around the four things that make a seek bar quietly wrong, each measured rather than assumed. (1) duration is NaN before metadata loads and Infinity for a live stream — and for every clip MediaRecorder produces — so pointerX / width * duration is NaN exactly on first paint; assigning a non-finite currentTime throws TypeError rather than seeking, which unwinds the handler and leaves a drag stuck to the pointer. Every path here is guarded for finiteness, not for range, because the element already clamps finite out-of-range values itself. A duration prop covers the MediaRecorder case where the file never learns its own length. (2) buffered is a TimeRanges, which is not iterable, has no .map, and has no index properties: [...buffered] throws, and Array.from(buffered) succeeds and lies — it returns an array of the right length full of undefined. It is read by index here, and the buffered bar stops at the range containing the playhead rather than the last range, so a buffer with a hole in front of the playhead is not drawn as a clip that will play through. (3) timeupdate fires at most four times a second, so the bar reads currentTime on an animation frame instead — re-reading the element, never accumulating its own clock — and commits only when the playhead would move to a different position on the track. (4) the thumb follows the pointer during a drag rather than currentTime, because seeking is asynchronous and the element keeps reporting the old position until it completes, so a bar rendered from currentTime crawls out from under the finger. Accessibility is the part a hand-rolled bar usually skips: role=slider with aria-valuemin/max/now and an aria-valuetext in words, all three value attributes withheld together while the length is unknown (publishing valuenow alone states 0% of a clip nobody has measured, because ARIA's implied maximum is 100), aria-disabled until a seek is actually possible, Arrow and Page keys, Home and End, a focus-visible ring on the thumb, horizontal arrows mirrored under RTL while the vertical ones are not, touch-none so a drag on a phone is a seek and not a page scroll, pointer capture with pointer-id checks so a second finger cannot move or end someone else's drag, and the release's own coordinate committed rather than the last coalesced pointermove. Common asks it answers: "audio player progress bar react", "video seek bar react", "custom audio player scrubber react", "react media player progress bar", "shadcn audio player", "shadcn video player progress bar", "shadcn seek bar", "shadcn media scrubber", "html5 audio currentTime react", "react audio seek slider", "accessible audio seek bar", "aria slider for audio position", "screen reader audio player position", "audio buffered progress bar react", "show buffered range audio react", "video buffered bar react", "buffered.map is not a function", "buffered is not iterable", "TimeRanges not iterable", "Array.from buffered empty", "how to read TimeRanges", "audio duration NaN", "video duration NaN react", "duration is Infinity audio", "MediaRecorder blob duration Infinity", "recorded audio duration Infinity", "Failed to set currentTime non-finite", "currentTime TypeError non-finite", "setting currentTime throws", "seek bar thumb jumps while dragging", "progress bar jumps back after seek", "scrubber lags behind cursor", "choppy audio progress bar", "timeupdate only fires 4 times a second", "smooth progress bar requestAnimationFrame audio", "progress bar stutters during playback", "seek bar does not work on mobile", "drag seek bar scrolls the page instead", "live stream duration Infinity seek bar", "podcast player seek bar react", "voice note playback react", "keyboard accessible video scrubber", "seek with arrow keys react", "next.js audio player progress bar", "tailwind audio seek bar", "typescript audio scrubber component", "音声 シークバー react", "動画 再生位置 バー", "オーディオプレイヤー 進捗バー", "シークバー 自作 react", "再生位置 アクセシビリティ", "ボイスメモ 再生 react". Different from official shadcn/ui, which ships nothing for media at all — measured across all 62 of its components and 223,287 bytes of source, currentTime, buffered, seeking, TimeRanges, <audio>, <video>, scrubber, seekable and playbackRate are all zero hits, and so is role="slider". Its slider is a Radix value slider with no notion of time, buffered ranges, an unknown or unbounded duration, or a position announced as a clock reading; its progress is a display-only bar you cannot seek with. Distinct from the neighbours in this registry: `audio-recorder` is the capture half and hands playback to the browser, `duration-input` types a length rather than seeking one, `progress-ring` and `ratio-bar` display a proportion you cannot drag, `countdown` counts time down rather than pointing at a position in it, and `scroll-progress` tracks the page rather than a media element.
The comment box where typing `@` opens a list of people and picking one types their handle in — the completion that lives inside the prose, anchored under the caret rather than under the field. Reach for it wherever text is addressed to somebody: a code-review or pull-request comment, an issue or ticket description, a chat, DM or channel composer, a task assignment note, a document or design comment thread, a support reply, a release note that credits people, and an AI prompt box where `@` should pull in a file, a table or a teammate. The trigger is a prop, so the same field does `#` for issues, milestones or channels, `/` for commands and `:` for emoji. Common asks it answers: "mention input react", "@ mention textarea", "react mentions component", "autocomplete inside a textarea", "tag someone in a comment box", "@mention dropdown react", "slack style mention input", "github comment @ autocomplete", "shadcn mention input", "shadcn textarea autocomplete", "react-mentions alternative", "tribute.js alternative", "textarea caret position javascript", "get caret coordinates in textarea", "position dropdown at cursor react", "how to know where the cursor is in a textarea", "mirror div caret", "insert text at cursor react", "insertText execCommand react", "keep undo history when inserting text", "cmd+z broken after setState textarea", "mention autocomplete ignores email addresses", "detect @ but not in email", "IME enter selects autocomplete item", "日本語入力 変換確定 enter 誤爆", "メンション入力 react", "テキストエリア キャレット 座標". Official shadcn/ui has none of this, 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 245 KB of source: mention, selectionStart, selectionEnd, setSelectionRange, execCommand, insertText, contentEditable, autocomplete and aria-activedescendant are every one of them zero hits. The single caret match is input-otp drawing a fake blinking one. Its textarea is twenty-three lines of styled element, and its combobox is a @base-ui/react popover hung off its own input group — a field whose whole value is the thing you picked, which is the opposite shape from a paragraph with three names in it. So an agent asked for a mention box builds it out of a plain textarea and a div, and the four things that make it hard are exactly the four it will get wrong. The first is that the menu has to appear at the caret, and the browser will not say where the caret is. There is no API for it: selectionStart is an index into a string and nothing converts one to pixels. The only way is to lay the text out a second time in a hidden mirror wearing the field's font, padding, border, width and wrapping, put a marker where the caret would be, and measure that — which is why the version that skips it pins the menu to a corner of the field, and offers suggestions next to line one for an `@` typed on line three. Details that decide whether the mirror is right: a copied border-width lays out as nothing without a border-style, getComputedStyle resolves width to the content box whichever box-sizing is in force (so copying box-sizing shrinks the column and rewraps every line), a trailing newline needs something after it or the last line never exists, and the field's own scroll has to be subtracted because the text moves under a caret that does not. The second is that finding the trigger is a word-boundary problem, not a search. Scan backwards for an `@` and every email address in the box opens the menu — and it is not an edge case, it is the first thing anyone pastes into a comment. A trigger welded to the end of a word is not a trigger. The other end matters as much: the query has to stop at the first space, or one stray `@` turns the rest of the paragraph into a search term and the list quietly goes empty. Both rules live in an exported pure function, findMentionQuery, along with a bound on how far back it scans, so a 40 KB comment costs the same as a short one. The third is the keyboard, and it is where this component makes its strongest claim. While the menu is open, Up, Down and Enter belong to the list; while it is closed they belong to the textarea, and Shift+Enter is a new line either way. But there is a third owner nobody accounts for: an open IME conversion. Enter commits the reading, the arrows walk the candidate window, and a mention box that takes those keys leaves a Japanese, Chinese or Korean writer unable to finish a word — they press Enter to accept 山田 and a name they never chose lands in the text instead. Every key is checked against isComposing and against keyCode 229, which is what the browsers that clear isComposing early report instead, and compositionstart is tracked on top of both. The menu itself keeps updating during the conversion, because suppressing it would leave that same writer typing blind; it is the keys that are borrowed, not the list. The fourth is undo, and it is invisible until someone hits Cmd+Z. Writing the new value with setState looks identical on screen and empties the browser's undo stack, so one undo after picking a name wipes the entire comment rather than stepping back over the insert — because as far as the browser is concerned, nobody typed anything. The insert goes through execCommand("insertText") instead, deprecated and still the only way to put text into a field as though a person had, so the undo entry exists and the input event fires like any keystroke. Where that is unavailable the fallback writes through the prototype's value setter rather than the element, because React installs its own value property on the node to track changes: assign to the element and the tracker updates as a side effect, the input event that follows is discarded as "no change", and a controlled field ends up showing text its owner never received. Accessibility is the ARIA 1.2 editable-combobox pattern, complete rather than approximated: the textarea carries role="combobox" with aria-expanded present whether the menu is open or shut, aria-controls and aria-autocomplete="list", and the highlighted row is tracked with aria-activedescendant so DOM focus never leaves the text — moving it into the list would take the caret with it and there would be nothing left to insert into. The cost is stated plainly: a field with that role is announced as a combobox even while no menu is open, which is the price of the menu being reachable at all. The live region follows char-counter's discipline rather than the reflex — announcing the count on every keystroke makes a screen reader read numbers over the letters being typed, so the count is spoken once when the menu opens, and again only when the matches run out. That last one matters more than it looks: no matches usually closes the menu, and a writer who cannot see it vanish is otherwise told nothing at all about the name they just typed. The API: items of { id, label, value?, description?, disabled? } — value is the text typed in when the label has a space in it, since `@Ada Lovelace` is not a token anything can find again and `@ada` is. Controlled with value and onChange or uncontrolled with defaultValue, on a real <textarea> that forwards its ref, so labels, react-hook-form and native validation keep working. filter takes your own matcher or false for a server-side one, and onQueryChange reports the query as it changes (and null when it closes) for the async lookup, with loading and showEmpty for the states that lookup goes through. Also trigger, maxItems, maxQueryLength, allowSpaces for names with spaces, toInsertText, renderItem, and labels for every string. The default matcher folds accents so "jose" finds José, and ranks a match at the start of a word above one buried inside it, so "@love" puts Ada Lovelace above Clover. useMentionInput returns the whole behaviour as props to spread onto a field you lay out yourself — it takes onInput rather than onChange precisely so it does not collide with the value plumbing you already have — and findMentionQuery, defaultMentionFilter, insertMentionText and measureCaretPosition are exported for the times you want one part of it. Within pulld it is the third layer on the comment box: autosize-textarea is the field that grows, char-counter is the count underneath it, and this is what happens inside it — spread the hook onto AutosizeTextarea and all three compose. It is distinct from multi-select and tag-input, which are fields whose value is a list you assembled; here the value is prose that happens to have names in it. Distinct from command-palette too, which owns the whole screen to run a command rather than living in one field. One file, zero dependencies — not even an icon — and every colour is a shadcn token, so it follows light and dark.
One line of text with the middle removed so that both ends stay readable, fitted to whatever width the container actually gives it. Use it for file names — where ordinary CSS truncation eats the extension and every row ends up reading "quarterly-report-2026-fin…" — and for file paths and breadcrumbs, URLs, S3 and object-storage keys, IPFS CIDs, git SHAs and commit hashes, docker image digests, wallet and contract addresses, API keys, tokens and JWTs, request, trace and session IDs, branch and artifact names, email addresses in a narrow column, and any other identifier whose tail is the part that tells two of them apart. The places it usually goes: a file or storage browser, an uploads list, a table cell in a fixed-width column, a sidebar file tree, a build or deploy log, a commit list, an API-keys settings page, a connected-wallet button, and any breadcrumb that has to survive a narrow window. Common asks it answers: "truncate the middle of a string", "middle ellipsis react", "truncate middle component", "truncate a filename but keep the extension", "ellipsis in the middle of text", "shorten a wallet address to 0x1234…abcd", "truncate ethereum address react", "truncate a long path from the middle", "text-overflow ellipsis but centered", "css truncate middle", "tailwind truncate middle", "abbreviate a long ID", "responsive text truncation", "react-middle-truncate alternative", "react-truncate alternative", "smart ellipsis". CSS genuinely cannot do this — text-overflow: ellipsis only ever cuts the end, and there is no middle variant to reach for — and official shadcn/ui has no truncation primitive anywhere: its table and card are layout, scroll-area scrolls text it never shortens, and tooltip can show the full value but cannot decide what the short one should be. So it is normally hand-rolled as a fixed character count, which is wrong at every container width except the one it was tuned for. Distinct from pulld read-more, which expands a long passage the reader may want all of, from char-counter, which counts what is being typed rather than shortening what is shown, and from code-block, which wraps or scrolls rather than eliding. Pairs with copy-field and copy-button (the shortened key is the one you show, the full one is the one you copy), with upload-list and tree-view, whose rows are exactly these names. This measures the rendered text against the box it has to fit and binary-searches the cut point, so it fills the space exactly; it re-measures when the column resizes and again once web fonts have loaded, because a font swap changes every glyph width without changing the box. It cuts on grapheme boundaries using Intl.Segmenter, so an emoji, flag or accented letter landing on the cut does not become a replacement glyph the way a raw slice() would — which matters more here than elsewhere, since the cut point moves every time the container resizes. The full string stays in the DOM and only the visible copy is shortened: screen readers get the whole value instead of "0x4f2a ellipsis 91bc", find-in-page still matches it, and selecting the line copies the full text exactly once rather than the shortened form. Hovering shows the full value as a tooltip. It takes its width from its container — a flex row, a grid track, or a fixed width — and needs no min-w-0 to shrink; inside a shrink-to-fit parent there is nothing to fit to, so it simply renders in full. Zero npm dependencies, one file, and it inherits whatever type styles surround it rather than imposing its own.
A month picker: a year of twelve months as a grid, with arrows on the year, that hands back a plain "YYYY-MM" string — "2026-08" — and never a day or a time zone. Reach for it wherever the thing being chosen is the month itself: a billing or subscription cycle, the period on an invoice or a statement, a monthly report or export, the target month on an expense claim or a timesheet, payroll and accounting periods, budget and forecast months, a cohort in a retention table, the month a goal or OKR is scored in, "as of" month on a snapshot, the archive month on a blog or changelog, card expiry, and the period selector above an analytics dashboard, chart or ledger — usually paired as two of them for a from/to range. Common asks it answers: "month picker", "month year picker", "monthpicker react", "select a month component", "month and year select", "billing period picker", "monthly report period selector", "choose month for dashboard", "shadcn month picker", "shadcn calendar month only", "MUI DatePicker views month equivalent", "antd DatePicker picker=month equivalent", "react-datepicker showMonthYearPicker alternative", "YYYY-MM input". shadcn/ui has no month selection anywhere: its calendar is a react-day-picker wrapper that pulls in react-day-picker and date-fns and returns a Date for a day, and captionLayout="dropdown" adds month and year dropdowns for *moving* through that grid rather than for answering with a month; the Date Picker page is that same calendar inside a popover; select, native-select and combobox are empty controls that know nothing about months. Distinct from pulld date-input, which types a full date down to the day, and from pulld calendar-heatmap, which draws a year of days rather than choosing one of its months. The value is a calendar month rather than an instant, and the component holds that line: there is deliberately no Date accessor, because handing one back means having silently picked a day and a zone — the bug that starts a billing period on the last day of the previous month for everyone west of UTC. `toMonthValue(date)` reads local fields going in (the "toISOString().slice(0, 7)" one-liner is a month early for half the planet after 22:00), `parseMonthValue` gives back { year, month } and rejects anything that is not a bare month, and the strings sort and compare as they read. Month names come from `Intl.DateTimeFormat`, so the grid is already in the reader's language with zero dependencies — no date library, no icon package, one file. Both the locale and the "which month is now" marker are resolved after mount, so a server render and the browser's first paint agree instead of tripping a hydration mismatch, and passing `locale` skips the swap entirely. It is a real `role="grid"` with a roving tabindex — one tab stop for the whole year, then arrow keys inside it. Left and right step a month and cross into the neighbouring year at the edges rather than dead-ending in December, up and down move a row and follow the `columns` prop, Home and End go to January and December (rows here are a layout choice, not a calendar week), and PageUp/PageDown hold the month and walk the years. On an RTL page left and right follow the writing direction instead of running backwards. Every cell is named with the month spelled out and its year — "August 2026", localised — because "Aug" alone stops meaning anything once the arrows have moved, and the current month carries aria-current="date". `min` and `max` take the same "YYYY-MM" strings and stop the year arrows as well as the cells, `isMonthDisabled` handles scattered holes like closed accounting periods without locking the arrows, and unavailable months are marked with aria-disabled rather than disabled so they can still be reached and read instead of being invisibly skipped. Give it a `name` and it posts with a plain form or a server action through a hidden input. Uncontrolled, controlled, or controlled on the year alone; a value set from outside pulls the grid to that year so the selection is never off screen. Styled entirely with shadcn tokens (primary, accent, input, ring, muted-foreground), so it follows light and dark mode.
A searchable multi-select: choose several options from a list you define, each shown as a removable badge in the trigger. Reach for it wherever a field stores an array rather than one value: assigning tags, labels, topics or categories to a post, product, ticket or issue; picking assignees, reviewers, attendees, team members or recipients; granting permissions, roles, scopes or groups; filtering a table, dashboard or report by several statuses, owners, channels, regions or vendors; choosing skills, industries, languages, integrations or notification channels; deciding which columns a table shows; and the "applies to" row on a rule, policy, coupon or automation. Common asks it answers: "multi select", "multiselect", "shadcn multi select", "shadcn multiselect combobox", "select multiple react", "multiple select dropdown", "multi select with search", "multi select with badges", "checkbox dropdown", "react-select alternative", "cmdk multi select", "combobox multiple", "assignee picker", "tag select", "accessible multi select", "select several options". Official shadcn/ui has no multi-select of any shape: across all sixty-three of its components the word "multiple" does not appear once, and its select, native-select and combobox are single-value by construction. The two things it has that hold more than one answer are a different tool — checkbox is one control per option, and toggle-group takes Radix's type="multiple" but is a row of always-visible buttons. Both stop working somewhere around a dozen options; this is the control for the list that has to be searched, and it is the gap that sends people to react-select or a hand-rolled cmdk popover. Distinct from pulld's tag-input, and the pair is worth knowing: this picks from a fixed set of options you supply and returns their values, while tag-input lets someone type new free-text tags that did not exist before. Choose by whether an unknown answer is allowed. Built on the select-only combobox pattern rather than a div with click handlers: the trigger is role="combobox" with aria-expanded and aria-haspopup, the panel is an aria-multiselectable listbox, the highlighted row is tracked with aria-activedescendant, chosen rows carry aria-selected with a check, and every add and remove is announced through a polite sr-only live region — the part a hand-rolled version always omits, which leaves a screen-reader user with no confirmation that anything happened. The keyboard is complete: Enter, Space or Down opens; arrows move; Enter toggles; Escape closes; Backspace removes the last badge from the trigger, and again from an empty search box; each badge's × removes just that one and never opens the panel. Type in the built-in search box to filter, or pass hideSearch for a short list and the listbox itself takes focus and the arrow keys. Works controlled (`value` + `onChange`) or uncontrolled (`defaultValue`), always a string[] of option values. `max` caps the selection and disables the unchosen rows at the cap rather than silently ignoring a click; per-option `disabled` blocks one row; `placeholder`, `searchPlaceholder` and `emptyMessage` are yours. Themed with shadcn tokens (secondary badges, accent highlight, popover surface, ring), so it follows light and dark mode. No Radix and no cmdk — one file, lucide-react for the three icons and your own cn util.
The checkbox list with parents and children that agree with each other: tick every child and the parent fills in, tick the parent and the whole branch follows, tick some of them and the parent shows the third state — a dash, announced as "partially checked" rather than as "not checked". Reach for it wherever a form hands out a hierarchy rather than a flat list: permissions, roles, scopes and API-token access on a settings or admin screen, category and facet filters on a storefront or search page, which columns or fields a CSV or report export includes, notification settings crossed with channels (email, push, Slack), per-purpose consent on a privacy or cookie screen, selecting files and folders for an upload, a download, a backup or a bulk move, choosing which locations, teams, projects or environments a rule applies to, picking a taxonomy or tag subtree, and the "select all" on a nested table of rows. Common asks it answers: "nested checkbox react", "parent child checkbox", "checkbox tree react", "tri-state checkbox", "three state checkbox", "indeterminate checkbox react", "half checked checkbox", "partially selected checkbox", "aria-checked mixed", "mixed state checkbox", "select all with children react", "check all children when parent is checked", "parent checkbox updates when children change", "permission tree component", "scopes checkbox tree", "category tree with checkboxes", "folder tree with checkboxes", "shadcn nested checkbox", "shadcn checkbox tree", "shadcn indeterminate checkbox", "radix checkbox indeterminate dash", "react-checkbox-tree alternative", "rc-tree checkable alternative", "MUI TreeView with checkboxes equivalent", "antd Tree checkable equivalent", "how to set indeterminate in react", "input indeterminate react no prop", "checkbox dash shows but screen reader says unchecked", "recursive checkbox state react", "nested checkbox three levels", "disabled child breaks select all", "親子チェックボックス react", "全選択 子要素 連動", "中間状態 チェックボックス", "indeterminate 実装". Official shadcn/ui has nothing for this, and the gap is narrower and more surprising than usual. Measured today over all 63 catalogue entries (62 are fetchable — questionnaire is listed and 404s on both style tracks) and 223,287 bytes of their source: indeterminate, mixed, aria-checked, partially, subtree and descendant are every one of them a zero hit, while checkbox itself appears 13 times. So the checkbox is there and the third state is not. Reading its source: the indicator holds a CheckIcon and nothing else — no dash, and no branch on which state it is in — and every colour rule on the box is keyed to data-[state=checked], so the indeterminate state the Radix primitive underneath it can represent has no appearance of its own in the wrapper at all. Composing on that component and passing the third state through is the obvious move and it does not produce a third state, which is why this one is self-contained. Within this registry the neighbours are close enough to be worth naming. tree-view is the ARIA tree pattern — one tab stop for the whole tree, arrow keys, rows that open and close: that is hierarchy you navigate, this is hierarchy you select, and the two are routinely wanted on the same screen. multi-select and tag-input put chips inside a single field and choose from a flat list. bulk-action-bar is about the rows already selected rather than the act of selecting them, and filter-chips shows which conditions are in force. Nothing here held a checkbox before this. Six things separate it from the thirty-line version, and five of them are invisible until somebody is already relying on it. First, and the reason it is not an <input type="checkbox">: the third state is a value, aria-checked="mixed", not a dash drawn in CSS. indeterminate is a DOM property with no HTML attribute behind it, so React cannot express it in JSX — it has to be assigned to the node through a ref after mount, which means the server render, the first paint, a snapshot test and any crawler all see aria-checked="false" on a row that is actually part-selected. That is the exact bug where the dash is on screen and a screen reader says "not checked", and it survives every visual review because the picture is right. Each row here is a role="checkbox" button, so the state is an attribute and the markup is true at every moment. Second, the roll-up runs in both directions or it is visibly broken. A parent's state comes up from its children and is never stored — keep a parent's own boolean beside the children's and you have two sources of truth that disagree the moment either side is touched — while a press on a parent goes back down and writes every leaf beneath it. Implement only the upward half and pressing a parent does nothing; only the downward half and the parent goes stale as soon as a child is pressed on its own. Third, it recurses. A parent of parents of leaves rolls up through every level, which is the part that looks finished at two levels deep and breaks the day somebody nests a third — and the state and the write are each one post-order pass over the tree, not a walk per row. Fourth, disabled rows, which is where the careful implementations still go wrong, because the question has two different answers. What a parent displays counts its disabled children: a group claiming "all of it" while a visibly empty row sits underneath is lying about what will be submitted. What a press changes excludes them: a locked permission must not be granted by pressing its group. Decide the direction of the press from the displayed state and a parent held at mixed by one disabled-and-off row can never clear itself — it is not "checked", so every press reads as "turn it all on" and the thing can be pressed forever without changing. The direction here comes from the leaves it may touch, so the group clears on the first press. Disabling a parent disables its whole subtree, and a row with nothing togglable beneath it reports itself as such instead of looking live and doing nothing. Fifth, ids it does not recognise are carried through untouched rather than rebuilt from what is on screen, so a tree that is filtered, paginated or lazily loaded does not quietly discard the choices made on another page — and the emitted array keeps the order it arrived in, appending additions in tree order, so it is stable across presses instead of reshuffling on every change. Sixth, the locked rows are aria-disabled rather than carrying the disabled attribute, so they stay focusable and somebody tabbing through can still discover that a permission exists and is held shut; a node's description is wired up with aria-describedby from outside the control, so it is announced after the name instead of swallowed into it; and each subtree is a role="group" named by the row above it, so entering it says which group these rows belong to. The value is the set of checked leaf ids — group ids never appear in it, because a derived state that is also saved is the drift described above — controlled with value plus onChange (handed the next ids and the node that was pressed) or uncontrolled with defaultValue. collectLeafIds, nodeCheckState and toggleCheckboxNode are exported as pure functions, so a check-all, an "is this whole branch on" test for a payload, and the roll-up itself are all usable headless. Each checkbox is its own tab stop the way a list of checkboxes in a form is, and Space toggles. Pass a name to submit the selection with a plain form POST as one hidden input per id. Styled entirely with shadcn tokens (primary, primary-foreground, input, background, ring, muted-foreground), so it follows light and dark mode, and lucide-react for the two icons is the only dependency — no Radix, no state library.
An offline banner that is right about being offline: it tells someone the page has lost the network, and — the harder half — only tells them it is back once that has actually been verified. Reach for it wherever losing the connection loses work or misleads the reader: a long form, an editor or a checkout someone is mid-way through; a dashboard, wallboard or monitoring view whose numbers stop being true the moment the feed dies; a chat, inbox or collaborative document where silence reads as "nobody is talking"; a PWA, field app or point-of-sale used on a phone that drifts in and out of coverage; and any app that queues writes to flush on reconnect. Common asks it answers: "offline banner", "offline detection react", "network status component", "useOnline hook", "useNetworkStatus", "detect offline react", "navigator.onLine react", "connection lost banner", "reconnecting indicator", "internet connection detector", "react-detect-offline alternative", "shadcn offline banner", "no internet message component", "online offline event react", "captive portal detection", "heartbeat ping component". The reason to install one rather than write it is that the three-line version everyone writes is wrong, and wrong in the direction that matters. navigator.onLine does not report whether the internet works; it reports whether the machine has a network interface that is up. A laptop joined to a café or hotel access point whose portal has not been logged into reads online. So does one on Wi-Fi whose upstream has died, one behind a captive portal that answers for every server with its own login page, and one where DNS alone has stopped resolving. Every one of those is true while nothing whatsoever loads — so a banner built on that flag stays hidden through precisely the outages people complain about, and the window.addEventListener("online") that clears it fires when an interface came up, not when anything can be reached. The false direction is the trustworthy one: the browser is not wrong about having no interface at all. So this component believes false immediately and treats true as a claim to be checked, by actually asking the network for something. What that costs is kept honest, because a component that invents a request every few seconds forever is one people rip out. Mount does not probe — the page in front of the user arrived over the very network in question, and its own load is the freshest evidence there is. The online event starts a probe instead of being believed, and only the probe's answer clears the banner. The offline event lands immediately, with nothing to wait for. While unreachable, probes back off exponentially with jitter, because everyone whose access point rebooted starts their backoff on the same tick and would otherwise arrive back together at the worst possible moment — and they stop entirely when the interface itself is down, since there is nothing to ask and the event will say when there is. A hidden tab probes nothing at all and restarts on visibilitychange, which is also what catches the laptop that slept and woke up on a different network. Steady polling while everything is fine is off by default and there when a wallboard needs it. The probe itself is two details a hand-rolled fetch misses. It refuses to follow redirects, which is what turns a captive portal's 302-to-its-own-login-page back into the failure it is rather than a perfectly good 200. And it counts any HTTP response as reachable, a 404 or a 502 included: the question is whether packets get to a server and back, and a 404 answers it as well as a 200 does — which is why the default /favicon.ico is safe on a site that does not have one, and why a version checking res.ok reports such a site as permanently offline. A deadline is enforced too, because a black-holed connection does not fail, it hangs. Official shadcn/ui has nothing here: no offline, online or network item, and navigator.onLine, the online/offline events and any form of reachability check appear nowhere in its sixty-three components. Within pulld it is the detector, not another notifier — toast is the right home for "it worked / it failed" messages your code decides to send, while this one works out, on its own, whether the network is actually there. useNetworkStatus() is exported for a bar of your own design, or for pausing polling, disabling a submit button and flushing a queue on reconnect, and it hands back a check() to call the moment one of your own requests fails — a far better signal than any poll. checkReachable() and nextProbeDelay() are exported too. The wording sits in an always-mounted polite live region, because a live region inserted together with its text is not reliably announced and the banner would be silent for exactly the people who cannot see it. Every colour is a shadcn token, so it follows light and dark, and the whole thing is one file.
A button that asks for browser notification permission at a moment you choose, and refuses to ask when asking cannot work. Reach for it wherever a notification is the point rather than the decoration: a chat, support inbox or team channel that needs to reach somebody who has tabbed away, an alert or on-call dashboard, a long export, build, render or import that finishes minutes later, a CI run or deploy a developer is waiting on, a calendar or meeting reminder, a reminder, habit or medication app, a delivery or order-status page, a live auction or bid about to close, the other player's turn, a price or stock watch, and the settings screen where somebody goes looking for the switch afterwards. Common asks it answers: "notification permission react", "Notification.requestPermission react", "ask for notification permission button", "browser notification permission component", "request notification permission shadcn", "useNotificationPermission hook", "web notifications react hook", "enable notifications button react", "notification permission denied forever", "can't ask for notification permission again", "Notification.requestPermission does nothing", "requestPermission resolves denied immediately", "how to reset notification permission", "notification permission three states", "default granted denied notification", "detect when user allows notifications", "listen for notification permission change", "navigator.permissions.query notifications", "new Notification does not show anything", "Notification constructor silently fails", "notification not showing but no error", "requestPermission callback vs promise", "notification permission in iframe", "notification permission ios safari", "notifications only work in installed pwa", "showNotification service worker react", "two enable notification buttons out of sync", "should i ask for notification permission on page load". shadcn/ui ships nothing that touches notifications: measured against the live catalogue on 2026-10-28 (65 items indexed, the 62 fetchable ones concatenated to 223,287 bytes of source), Notification, requestPermission, navigator.permissions, PermissionStatus, permission, serviceWorker, showNotification and isSecureContext are all zero hits. pulld has nothing either — the single occurrence of the word anywhere in the registry is toast's aria-label="Notifications", which is a string and not an API. It is the asking half of the family that pulld install-prompt and geolocation-button belong to, and the loud counterpart to pulld tab-title-badge, which tells a reader something is waiting without asking anyone for permission at all; on iOS, where notifications are reachable only from an installed app, install-prompt is the route to get there first. The reason to install this rather than write the four lines is that the four lines spend a thing you only get once, and the platform gives almost no sign of it. Measured in Chrome 152 rather than remembered. One: on an origin that has already been blocked, Notification.requestPermission() resolved "denied" in 0.3 milliseconds, with no prompt and without needing a user gesture to do it, then did the same three more times in under a tenth of a millisecond each. There is no retry and no second chance — the call is a lookup wearing the costume of a request — so a "Try again" button is decoration, and the only honest interface for that state says the browser will not ask again and points at site settings. This component refuses the press itself rather than handing it to an API that will answer instantly and identically forever. Two: new Notification("hello") on a blocked origin does not throw. It constructs, returns an object, displays nothing, and fires an error event instead — measured: onerror fired within 600ms while onshow and onclose did not. So the natural `try { new Notification(t) } catch {}` reports success to a caller whose user is seeing nothing, permanently. notify() here checks the permission before constructing anything and reports from the event, never from the absence of an exception; when neither event arrives inside a second it answers "unknown" rather than claiming success it did not observe. Three: a sandboxed or cross-origin embed reads exactly like a refusal. Measured in an iframe with sandbox="allow-scripts": location.origin is the string "null", Notification is still a function, permission reads "denied", and requestPermission() resolves "denied" with no prompt — identical to somebody who pressed Block, and the opposite advice. Telling that user to fix their site settings sends them hunting for a switch that does not exist, so denied carries a reason, "blocked" or "embedded", and the copy follows it. Four: Notification.permission is an accessor on the constructor (measured: a getter, no value), so it is never stale and will never tell React it changed. Reading it during render is a hydration mismatch as well, so the first paint is deterministic and an effect corrects it — the button renders nothing until it knows, rather than flashing an offer at somebody who accepted months ago. The live value arrives through navigator.permissions.query({ name: "notifications" }), which hands back a real PermissionStatus with a change event, which is what turns "change it in your settings" into advice that visibly works instead of a button that stays dead until a reload. A descriptor a browser does not recognise rejects with a TypeError rather than answering, so not knowing is handled as an ordinary answer and every path works without it. Five: requestPermission has two signatures and must be called once. The promise form is what runs today (measured: arity 0, returns a real Promise), but the callback form is older and is the only one some browsers shipped, where the call returns undefined and `await undefined` coerces to "not granted" — throwing away an answer the person gave. The obvious repair, await the promise and call again with a callback if it was undefined, asks twice: two prompts for one press, the second spending the chance the first was still waiting on. The deprecated callback is an optional trailing argument of the promise form, so one call serves both. Concurrent presses collapse too — the permission is one fact about the origin, not a property of a button, so a request in flight is shared and two controls on one page settle together. The API is the hook and the button. useNotificationPermission() returns status ("default" | "granted" | "denied" | "unsupported"), canAsk, deniedReason, isRequesting, isResolved, request() and notify(); request() resolves "granted", "denied", "dismissed" — a prompt closed without an answer stores nothing and really can be asked again, which is the one denial worth offering a retry for — or "unavailable". notify(title, options) prefers an existing service-worker registration where there is one and registers none of its own, falling back to the constructor. NotificationPermissionButton takes label, requestingLabel, grantedLabel, deniedLabel, unsupportedLabel, hideWhenGranted, iconOnly, hideMessage, per-situation messages, containerClassName and onOutcome. isNotificationSupported and isOpaqueOrigin are exported for the parts worth overriding. Accessible by construction: the message region is mounted empty from the first render rather than inserted when something happens, because a live region that arrives already holding its text is not reliably announced; the refusal is aria-disabled rather than disabled, so the control keeps its place in the tab order and the one explanation of why notifications are off reaches the people who most need it; the button carries aria-busy while the prompt is open, icons are aria-hidden, icon-only keeps the label as the accessible name, and a caller's own aria-describedby is merged rather than overwritten. Styled with shadcn tokens (input, accent, ring, muted-foreground) so it follows light and dark, className merges rather than fights, server-rendering safe, one file, no notification library.
A number field with − and + stepper buttons either side of it. Reach for it wherever someone adjusts a number by one rather than typing it: a quantity picker in a cart, checkout or order form, seats, guests, rooms, tickets or attendees, a per-page or page-size control, retries, timeout or concurrency in a settings panel, font size, padding or spacing in an editor — anywhere you would otherwise reach for a bare <input type="number"> or a spinbutton. Common asks it answers: "number input react", "shadcn number input", "shadcn ui number field", "quantity selector react", "quantity picker component", "quantity input cart", "stepper input react", "react number stepper", "increment decrement input react", "numeric input with plus and minus buttons", "spinbutton react", "input type number without spinners", "hide number input arrows css", "remove spinner from input type number", "number input min max clamp react", "number input step decimal", "0.30000000000000004 step react", "floating point drift number input", "inputMode decimal react", "numeric keypad mobile input react", "number input react-hook-form", "number input controlled uncontrolled warning", "setting state from a button does not fire onChange", "react-number-format alternative", "数量入力 react", "プラスマイナス付き 数値入力", "数値ステッパー shadcn", "input type=number の矢印を消したい". It keeps type="number" underneath, so native validation and valueAsNumber still work, and fixes the parts of it that are unpleasant in practice: the browser's own spin buttons are hidden (they are inconsistent across browsers and absent on mobile) and replaced with real, theme-aware, keyboard-reachable ones, inputMode="decimal" brings up the numeric keypad on phones, and the value is tabular-nums so the digits do not jump as they change. Decimal steps do not drift: stepping by 0.1 counts the step's decimal places and rounds to them, so you get 0.3 rather than 0.30000000000000004. Each button clamps to min or max and disables itself once the value is at that bound, and the buttons are taken out of the tab order so Tab still lands on the field itself. The step is written through the input's native value setter and dispatches a real input event, which is the part that is easy to get wrong: onChange fires whether the field is controlled or uncontrolled, so react-hook-form, Formik and plain useState all see the change instead of silently missing button presses. Only one of value/defaultValue ever reaches the input, so React never warns about switching between controlled and uncontrolled. Official shadcn/ui has no number field: its input is a bare 768-byte element, and input-group and field are assembly kits that pull in button, input, textarea, label and separator without a line of numeric logic between them — no stepping, no clamping, no min/max state. This depends only on lucide-react for the two icons.
Two number boxes that behave as one field: a low end, a high end, and a single verdict over the pair. Reach for it wherever a page asks for "from X to Y" in numbers: a price filter on a catalogue, search or marketplace listing page, a stock or quantity-on-hand filter in an admin table, a score, rating or review band, an age range on a form or audience targeting rule, a salary or compensation band on a job posting, the two thresholds on an alert or automation rule, a size, weight or dimension filter, a row-count or amount filter on a report, a latency, duration or response-time window on a dashboard query, a discount or margin band, a year or model-year range, a min/max pair that goes straight into a query string as ?min=&max=. Common asks it answers: "number range input react", "min max input react", "react min max number field", "price range input react", "shadcn price range filter", "shadcn min max input", "shadcn number range", "shadcn range input not slider", "min max filter component", "from to number input", "two number inputs one field", "range input with text entry", "type exact price range instead of slider", "price filter without slider", "shadcn slider vs range input", "open ended price range", "min only price filter", "1000 and up filter", "empty max means no upper limit", "empty number input becomes 0", "Number('') returns 0 bug", "cleared input treated as zero", "min greater than max validation react", "swap min and max automatically", "should I swap min and max or show an error", "validate range on blur not on change", "number input validation while typing is annoying", "field turns red while typing react", "cannot type decimal in controlled number input", "leading zero removed from input react", "decimal point disappears when typing react", "react controlled number input deletes what I type", "debounce range filter query", "do not fire query on every keystroke", "useSearchParams controlled input loses keystroke", "router push controlled input resets", "type number input discards invalid value", "input type number value empty string", "scroll wheel changes number input", "inputMode decimal", "fieldset legend two inputs", "aria-describedby two inputs one error", "accessible min max input", "screen reader range error", "aria-invalid which input", "thousands separator input parsing", "comma decimal separator input", "1,5 parsed as 15 bug", "数値 範囲 入力 react", "最小 最大 入力", "価格帯 フィルタ", "下限 上限 入力欄", "空欄を0にしない". Official shadcn/ui has no component of this kind, and the measurement is unusually clean: across its whole registry today (63 items listed, 62 fetched, 223,287 bytes of component source; `questionnaire` 404s on both styles) the words `minValue`, `maxValue`, `rangeStart`, `rangeEnd`, `fromValue`, `toValue`, `clampRange` and `lowerBound` return zero hits each, and so does `inputMode`. Its `slider` is the near neighbour and is genuinely close — it renders one thumb per value, so `value={[50, 500]}` does give you two — but it is a thing you drag inside a fixed track: there is no `<input>` to type an exact figure into, and a track cannot express an open end, so "1000 and up" is not a range it can hold. What you get: `NumberRangeInput`, the whole field — a `<fieldset>` named by its `<legend>`, the two boxes, the mark between them and one status line that carries either the error or a plain-language summary of the range ("50 to 500", "1,000 and up", "Up to 500"). Nothing is judged while you are typing: narrowing an upper end from 500 to 60 passes through 6, where the range is briefly crossed, so the verdict waits for a pause (`debounceMs`, 300ms) or for focus to leave the field, and `onCommit` fires on that same beat so a live query never goes out for the half-typed figure. `onValueChange` is the immediate one for parents that want per-keystroke state. Crossed ends are reported rather than quietly repaired, because swapping them the moment they cross takes the number you are halfway through typing and puts it in the other box; `crossBehavior="swap"` is there for filter bars that prefer it, waits until focus leaves, and announces that it swapped. An empty box is an open end and not a zero, so a one-sided range is valid and committable, and `requireBoth` turns that off where both really are needed. `bounds` sets the outer limits either end may take, inclusive, and is reported per end against the limit that was broken. `checkRange` and `parseBound` are exported as plain functions so the route handler receiving `?min=&max=` reaches the same verdict the field showed instead of carrying a second copy of the rules; `checkRange` returns issue codes rather than sentences, and every sentence the field can say is replaceable through `messages`. `parseBound` is deliberately stricter than `Number`: `Number("")` is 0, which is how clearing a filter turns into "zero and up", and `1e3`, `Infinity` and `0x10` all parse as numbers to it and as nothing to a price box. Group separators are accepted only where a group separator can go — `1,234,567` yes, `1,5` no — because stripping the character wherever it falls reads a comma one-and-a-half as 15, a bound wrong by a factor of ten with nothing on screen to suggest it; `decimalSeparator` says which convention is in force and is never guessed from the runtime locale, since `Intl` on the server and in the browser can disagree and a field that parses differently in the two passes is a hydration mismatch. The boxes are `type="text"` with `inputMode="decimal"`, not `type="number"`: a number input throws away text it cannot parse, so `input.value` is the empty string for `12e` and the reader's mistake vanishes instead of being explained, it steps its value when a scroll gesture passes over it while focused, and it refuses the half-typed `1.` this field has to accept. The status is attached to both inputs with `aria-describedby`, so the error reaches a screen reader from whichever box the reader is in, and which box is marked `aria-invalid` follows the problem's scope — a bad number marks that box, a crossed pair marks both, since neither number is wrong on its own. Tabbing between the two boxes does not settle the field, because that is movement inside it. Distinct from the neighbours: `number-input` is a single value with stepper buttons, `date-range-preset` is a range of dates chosen from named spans, `currency-input` and `duration-input` are single formatted values, and `filter-chips` shows and clears the filters a field like this one produces.
A one-time passcode field split into separate digit boxes — the "enter the 6-digit code we sent you" screen. Reach for it on any challenge step where a short numeric code is typed or pasted: two-factor and multi-factor sign-in (2FA/MFA), an authenticator app's TOTP code, an SMS or phone verification code, an email confirmation code, a magic-link fallback code, account recovery, a step-up check before a payment or a destructive settings change, device or TV pairing, and PIN entry. Common asks it answers: "otp input", "one time password input", "verification code input", "6 digit code input", "enter code boxes", "2fa code field", "sms code input", "confirmation code input", "pin input react", "segmented code input", "react-otp-input alternative", "input-otp alternative", "otp input without dependencies", "shadcn otp input". Official shadcn/ui covers the same screen with input-otp, and the difference is the dependency: that one is a wrapper around the third-party input-otp package (plus lucide-react for its separator), so installing it adds a runtime dependency and hands the caret and paste behaviour to a library. This is the same boxed UI written out in a single file with no dependencies at all — reach for it when the project is keeping its dependency list short, when a package has to be vendored or audited before it can be added, or when you want the behaviour in code you can read and change rather than configure. Digits only, by design: the value is stripped to digits and truncated to `length` (default 6) on every path in, so a controlled parent, a paste and a keystroke cannot disagree about what is in the field. Pasting a full code into any box distributes it across the rest and lands the caret on the last filled one, typing auto-advances, Backspace clears and steps back, and the arrow keys, Home and End move between boxes. The first box carries autocomplete="one-time-code", so iOS and Android offer the code from the incoming SMS, and every box is inputMode="numeric" with pattern="[0-9]*" for a numeric keypad on mobile. Every box is labelled "Digit N of 6" and the group carries a name of its own, so a screen reader user is told where they are instead of hearing six unlabelled text fields. Works controlled (`value` + `onChange`) or uncontrolled (`defaultValue`), fires `onComplete` once when the last box fills — on the fill only, not on every later edit — and forwards a ref to the first box so a page can focus it on mount or from a shortcut. Passing `name` mirrors the joined value into a hidden input, so it posts with a plain HTML form, a Next.js server action, or React Hook Form without a controller. Themed with shadcn tokens, so it follows dark mode.
Text that reveals itself in a tooltip only when it is actually cut off — measured, not guessed. Reach for it wherever a string has to fit a box somebody else sized: a table cell, a file, branch or project name in a sidebar, a tab label, a breadcrumb, a card or dialog title, an email subject line, a chip in a row of chips, a legend entry on a chart, a path in a file tree, a commit message in a list. Common asks it answers: "show tooltip only when text is truncated react", "detect if text is ellipsized", "is text truncated javascript", "check if element has ellipsis", "scrollWidth vs offsetWidth truncation", "useIsTruncated hook", "conditional tooltip on overflow", "title attribute only when needed", "tooltip for overflowing table cell", "ellipsis tooltip shadcn", "text-overflow ellipsis show full text on hover", "react truncated text tooltip", "line-clamp detect truncation", "webkit-line-clamp is text clamped", "show full text when clamped", "antd Typography ellipsis tooltip equivalent", "MUI Tooltip only if overflow", "don't show tooltip when text fits", "tooltip flickers on every cell", "title attribute read twice by screen reader", "accessible tooltip for truncated text", "keyboard access to truncated text", "truncated text not focusable", "ResizeObserver text truncation", "re-measure truncation on resize", "truncation wrong after font loads", "document.fonts.ready measure text", "省略されたときだけツールチップ", "文字が切れているか判定", "三点リーダー 全文表示", "テーブルセル 省略 ツールチップ", "title属性 読み上げ 二重", "切り詰め 判定 react". Official shadcn/ui has the tooltip and nothing that decides when to use one. Measured across its whole 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 — scrollWidth, offsetWidth and isTruncated are every one of them a zero hit, and the only two matches for ellipsis are BreadcrumbEllipsis and PaginationEllipsis, which are static three-dot glyphs you place yourself rather than a measurement of anything. tooltip is a Radix wrapper: it shows the tooltip you asked for, and the asking is the part that gets hand-rolled. So the reflex is title={name} on every cell, and the reflex is the expensive mistake. The negative rule is the whole component: showing a tooltip over text that was never clipped is worse than showing nothing. A tooltip on every cell is a table that flickers boxes at you as the pointer crosses it; a tab stop on every cell is a table a keyboard cannot get past; and because CSS clipping is visual only — the text node is complete whatever the paint does with it — title is a second copy of a string the screen reader already has, which it then reads twice. title is also slow, cannot be styled or dismissed, and never appears on touch at all. So here the tooltip, the tab stop and the handlers exist exactly while the text is clipped, and it is an ordinary span the rest of the time; the tooltip itself is aria-hidden, because nothing was ever hidden from assistive technology and the remedy is for eyes. The tab stop is for the sighted keyboard user who cannot hover, and it appears and disappears with the clipping. Three measurements decide it, and two of them are the ones hand-rolled versions miss. A box is clipped when scrollWidth exceeds clientWidth by more than a pixel of slack: sweeping a box through its text's natural width in tenths of a pixel, every box at least as wide as its text reported exactly 0, so the rounding cancels and there are no false positives from it — but a box 0.2px too narrow already reports 1, and a fifth of a pixel off the last glyph is not worth interrupting a table for. Fonts are the second: a box sized for the fallback face reports that its text fits and the same box is 8 pixels over once the real face swaps in, with no resize and no re-render to notice it, so a component that measures on mount is wrong from then on and wrong in the direction that calls a clipped cell fine — document.fonts.ready settles that once per page. Resizing is the third: dragging a sidebar or a column narrower flips a cell from fitting to clipped and the only notification is a ResizeObserver on the cell. Multi-line is a different axis, not a variation. A paragraph cut off by -webkit-line-clamp overflows downwards and not at all sideways — measured at a two-line clamp, 0 pixels of horizontal overflow and 51 of vertical — so a component that only ever compares widths says "not truncated" about every clamped paragraph it is given, silently. Pass lines={2} and the comparison moves to the height, and the clamp is set as a style rather than a line-clamp-N class, because that class only exists for the few N a build has already seen. The API: children (the text), label for the full string when the children are mixed content such as a highlighted match — an empty or blank label means there is nothing to show rather than an empty box, lines for a clamped paragraph, threshold for the slack, side to flip the tooltip below for the top row, delay for how long the pointer must rest (focus opens it at once, since arriving by keyboard is already a commitment), disabled for a cell whose full text is shown elsewhere, and onOverflowChange, which is told once per change rather than once per measurement, so a parent that re-renders on it cannot loop. isOverflowing is the comparison as a plain function of four numbers, and useIsOverflowing is the measurement with its observers, exported for a "show full" button or an expanding row that needs the same answer without this markup. Escape closes the tooltip and stops there, so it does not also close the dialog underneath. The tooltip is a sibling of the measured text rather than a child — verified against a browser, adding it leaves scrollWidth and clientWidth untouched — and it is positioned inside the wrapper rather than portalled, so an ancestor with overflow:hidden will clip it: put the scroll container on a parent it is allowed to escape, or use side="bottom" in the top row. In a flex row, give the parent min-w-0 or nothing truncates at all. Styled with shadcn tokens so it follows light and dark themes, and it ships with no dependencies at all — no Radix, no popper, no tooltip library. Distinct from its neighbours here: middle-truncate always shortens one string and does it in the middle so the extension survives, where this leaves the clipping to CSS and only decides whether to explain it; read-more folds a long body behind a control instead of revealing it on hover; scroll-shadow says there is more content past a scrolling edge, which is the same measure-then-observe discipline applied to a box rather than to a line; toolbar-overflow decides which whole controls survive a width; and highlight-text marks a match inside a string that may itself be the one being clipped here.
A password generator: a read-only field holding the generated password, a Generate control, a copy button, and switches for length, character classes and look-alike exclusion. Reach for it wherever a screen offers to invent a credential rather than ask for one — the "Suggest a strong password" affordance beside a sign-up, registration or change-password field, an admin creating a user or issuing a temporary password for a new employee, a settings screen minting an API key, access token, client secret or webhook signing key, a service-account or database credential during setup, a Wi-Fi or router passphrase printed for someone to type on another device, and any rotate-credentials or reset flow. Common asks it answers: "password generator react", "react password generator component", "shadcn password generator", "generate a strong password", "random password generator typescript", "secure password generator", "suggest a strong password field", "generate password button next to input", "password generator with length slider", "password generator with options", "exclude ambiguous characters password", "avoid look-alike characters 0O1lI", "generate an api key in the ui", "generate a client secret", "temporary password for a new user", "admin generate password for user", "rotate credentials ui", "wifi passphrase generator", "crypto.getRandomValues password", "is Math.random safe for passwords", "Math.random password insecure", "modulo bias random string", "unbiased random character from alphabet", "guarantee one digit and one symbol in a password", "fisher yates shuffle password", "password entropy bits", "calculate password strength in bits", "random value hydration mismatch next.js", "generator works locally but breaks in next.js", "copy generated password to clipboard". It is the third piece of a set and does the opposite job to the other two: password-input is the field a person types their own password into, password-strength scores a password a person chose, and this one produces a password nobody chose. shadcn/ui ships no generator — there is no crypto call anywhere in its registry — so an agent asked for one writes it inline, and inline is where it goes wrong in ways the output never shows. Math.random is a fast PRNG whose state is recoverable from its own output, so passwords built on it are not unguessable while looking exactly as random; folding a random number into the alphabet with % tilts the result toward the low characters; and satisfying "must contain a digit" by overwriting a fixed position tells an attacker where the digit is. This draws from crypto.getRandomValues, redraws the values that would bias the fold instead of folding them, and guarantees each enabled class by drawing one character per class and then shuffling with a Fisher-Yates pass whose indices come from the same unbiased source, so no position is special. It reports entropy in bits, which is honest arithmetic here precisely because the draw really is uniform. The first password is drawn in an effect rather than during render, so a server-rendered page does not hydrate with a mismatch — the failure that makes a generator look broken in a Next.js app while working perfectly in isolation. The exported generatePassword, buildPools and entropyBits work on their own for seeding, CLI use or tests. Look-alike characters (0O1lI) can be excluded for passwords read off one screen and typed into another. Every colour is a shadcn token so it follows light and dark, and the live region announces that a new password exists without ever speaking the password itself.
A password field with the show/hide eye already built in — the one control every sign-up form needs and shadcn/ui does not ship. It is a drop-in replacement for `<input type="password">`: every native prop passes straight through (`name`, `autoComplete`, `required`, `minLength`, `placeholder`, `disabled`, `onChange`), and the ref lands on the field itself, so `register()` from react-hook-form and a shadcn `FormField` bind to it exactly as they would to a bare input. Reach for it in sign-up and registration forms, login and sign-in screens, change-password and reset-password flows, the “confirm password” field beside it, the password half of a credentials dialog, a database or SMTP connection form, and any secret — an API key, a token, a recovery phrase — that a person has to read back to check they typed it correctly. The reveal toggle is where hand-rolled versions go wrong, so all five decisions are already made: the button’s accessible name follows the state (“Show password” when hidden, “Hide password” when shown) rather than freezing on one of them; `aria-pressed` reports whether the password is currently visible; the eye is `aria-hidden`, so a screen reader hears the name and not the glyph; the button is `type="button"`, so pressing it inside a form does not submit the form; and it is deliberately kept out of the tab order — a keyboard user tabbing off a password should land on the submit button, not on a reveal control they did not ask for. It stays reachable by mouse, touch and screen-reader navigation. Common asks it answers: “password input”, “password field with show hide”, “show password toggle”, “reveal password button”, “eye icon password input”, “toggle password visibility react”, “password visibility toggle component”, “hide password input”, “shadcn password input”, “shadcn password field”, “login form password field”, “sign up password input”, “react password input component”, “MUI password field equivalent”, “antd Input.Password equivalent”, “chakra password input equivalent”. shadcn/ui’s own `input` is the plain field — it takes a `type` and styles it, with no toggle, no eye and no mention of passwords anywhere in its source; `input-group` is a layout kit for placing an adornment beside a field, which leaves the toggle’s behaviour and its accessible naming for you to write. This is that field with both already decided. Pair it with this registry’s `password-strength`, the meter that goes underneath and scores how many guesses a password would survive: this component is the field, that one is the verdict on what gets typed into it. Needs `lucide-react` for the eye.
A password strength meter for a sign-up, registration, change-password or reset-password form — the bar under the password field that says Weak or Strong, plus one line saying why. It scores how many guesses a password would survive rather than ticking off one uppercase, one number, one symbol: composition rules push people toward Password1!, which is guessed instantly, and reject correct horse battery staple, which is not. NIST SP 800-63B says the same — screen against known-bad passwords and let length do the work. It detects the things that make a password look random without being random: entries from a built-in list of the passwords that top every breach dump (folded through leet substitutions, so P@ssw0rd is found where password is, and unaffected by capitalisation), repeated characters, runs through the alphabet or the digits, runs along a keyboard row, and years and dates. The check hand-rolled meters always miss is userInputs: pass the email, username, display name or your product name and Acme2026! stops scoring as strong on acme.com — including the joined-up forms that separators hide, so Acme Co catches acmeco. Pass blocklist to add your own breach list on top; the built-in one is deliberately small, because a real one is megabytes and belongs behind an API. estimatePasswordStrength is exported on its own, pure and synchronous, so the same score that draws the meter can disable your submit button or drive a zod refine — no async, no 800 kB zxcvbn bundle, no dependencies at all. Accessibility is the other half: the bars are a role="meter" with aria-valuetext, and only the band name sits in the aria-live region, so a screen reader hears "Weak" once when the password crosses a band instead of being read to on every keystroke — which is what an aria-live wrapped around the whole widget does. The advice line is tied to the meter with aria-describedby instead — give the component an id and it derives one for the advice and wires it up; without an id the advice is still read, just in document order rather than together with the meter. Warnings and suggestions come back as stable codes with an overridable message table, so the meter translates. It uses no hooks, so it renders in a server component and needs no "use client" of its own. Official shadcn/ui has nothing for passwords — no meter, no blocklist, no scorer; its input is a bare element and field and input-group are assembly kits with no logic in them. It scores a password but does not collect one: the field it sits under is this registry’s `password-input`, which is the same bare element with the show/hide eye and its accessible naming already wired. Pass this component that field’s value and the two compose into the whole password row.
A region that takes whatever is on the clipboard — a screenshot, an image, a file, a table copied out of a spreadsheet, or plain text — and hands it over as typed items, with each pasted picture given a name it can actually be filed under instead of the image.png that every browser calls it. Reach for it wherever the thing a person has is copied rather than saved: a screenshot going into a bug report, a support ticket or a QA tool, an image dropped into a chat message, a CMS entry or a comment box, an avatar or profile picture grabbed from somewhere else, a receipt or an invoice pasted into an expense claim, rows pasted out of Excel, Numbers or Google Sheets into an importer, a logo pasted into a brand or settings form, and a frame copied straight out of Figma during a design handoff. Common asks it answers: "paste image react", "paste screenshot upload react", "react paste from clipboard component", "onPaste handler react", "get image from clipboard javascript", "clipboardData.items getAsFile", "clipboardData.files is empty", "paste event has no files", "screenshot paste not working", "paste image works in chrome not safari", "navigator.clipboard.read example", "clipboard read permission denied", "NotAllowedError clipboard", "paste area shadcn", "shadcn paste component", "paste to upload", "ctrl+v upload", "cmd+v upload image", "paste excel table into react", "paste tsv into a table", "text/html vs text/plain clipboard", "pasted image has no filename", "every pasted file is called image.png", "paste anywhere on the page", "document paste listener react", "paste event only fires when focused", "paste event not firing on div", "クリップボード 貼り付け 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 223,287 bytes of source, onPaste, clipboardData, ClipboardEvent, DataTransfer, navigator.clipboard, getAsFile, ClipboardItem, clipboard.read and readText are every one of them a zero hit (measured 2026-09-29), and so is the word paste itself, in either case. It does not touch the clipboard anywhere. Distinct from the pulld pieces it sits beside, which all face the other way: copy-button and copy-field write to the clipboard, file-dropzone receives a file that already exists on disk by drag or by picker, camera-capture makes a new picture, image-crop and image-zoom work on a picture you already hold, and upload-list shows them afterwards. This is the only one that reads. It is deliberately stateless too — it hands each paste on and keeps no list, so it composes with whichever of those holds the files. Seven things separate it from the version built out of onPaste and e.clipboardData.files. First, the two clipboard mechanisms are available under opposite conditions, so both are wired: the paste event needs no permission and works everywhere but only ever reaches the focused element — which is why the region is focusable and why a global prop exists for a screen whose whole job is to receive one thing — while navigator.clipboard.read() can be called at any moment but prompts for permission, is not implemented everywhere, and in some engines only resolves inside a user gesture, which is why it sits behind a button. Second, that button appears only after mount: the server has no navigator, so deciding it during render puts a button in the client's first tree that is missing from the server's HTML, and React throws the whole tree away rather than reconciling that. Third, files and items disagree about the most common paste there is. A screenshot is a picture that was never a file on disk, so it turns up in items and, in some browsers, nowhere else — read files alone and the single thing people paste most is silently ignored. Both are read and then de-duplicated, because an ordinary copied file is usually in both; and the identity used for that is name, size and type but deliberately not lastModified, since getAsFile() mints a fresh File on every call and some engines stamp it with the current millisecond, so the two reads of one picture differ by a millisecond and the person is handed their screenshot twice. Fourth, one paste carries several flavours at once and choosing between them is not this component's call: a spreadsheet selection arrives as a real table in text/html and as tab-separated text in text/plain, and which one is read changes the result entirely, so both are handed over along with the full list of types. The strings are read with getData, synchronously, rather than with items[i].getAsString, whose callback runs on a later turn — by which time the event's DataTransfer has been emptied and it fires with nothing. Fifth, every pasted screenshot is called image.png. One is fine; the second lands in the list beside a file of the same name, and from there "which image.png?" is a question the UI cannot answer and an upload keyed on filename quietly overwrites. Placeholders are renamed with a timestamp and a sequence that continues across pastes rather than restarting, so two pastes inside the same second cannot collide either, and the original name is reported back. A file that came with a real name keeps it — photo.jpg is deliberately not on the list of placeholders, because renaming it would destroy the only information its owner supplied. Sixth, a paste that belongs to a text box is left to it. A region holding a caption field, or one listening on the document, is handed pastes meant for an input, and swallowing one means someone watches their text simply not appear; a read-only or disabled field is not one of those, since nothing can be pasted into it, so the region takes that one. Seventh, in global mode the element's own handler is dropped rather than added to, because a paste event bubbles to the document as well — keeping both delivers one Ctrl+V twice, as two copies of the same screenshot. Files are screened with accept, maxSize and maxFiles and anything skipped is reported through onReject instead of vanishing; every stage is announced through a live region that is in the DOM before it has anything to say, and visibly rather than only to screen readers, because "clipboard permission denied" is the answer to "why did nothing happen" and sighted people ask that too. One file, shadcn tokens throughout so it follows light and dark themes, and lucide-react is the only dependency.
The role × permission grid: rows are roles, columns are the things they may do, and every crossing is a checkbox. Reach for it on the screen where somebody decides who can do what — an admin role editor, a team or workspace permissions page, the scope list on an API key or personal access token, an RBAC or access-control settings pane, group policy for an org, a sharing dialog that has outgrown three radio buttons, per-plan feature entitlements, moderation or staff roles in a community tool, and the permissions step of an onboarding or provisioning wizard. Common asks it answers: “permission matrix react”, “role permission table component”, “RBAC UI react”, “role based access control component”, “permissions grid checkbox react”, “shadcn permission matrix”, “shadcn roles and permissions table”, “scopes checkbox grid”, “api key scopes ui”, “access control matrix component”, “checkbox matrix react”, “table of checkboxes rows and columns”, “select all row and column checkboxes”, “permission dependencies ui”, “write implies read permission”, “implied permissions checkbox”, “role inheritance ui”, “inherited permission greyed out”, “show inherited vs explicit permission”, “why can I not untick this permission”, “permission checkbox will not uncheck”, “unchecking a box that stays checked”, “cascade permissions on checkbox”, “tri-state select all column”, “aria-checked mixed grid”, “role=grid roving tabindex react”, “too many tab stops table checkboxes”, “arrow key navigation table react”, “accessible checkbox grid”, “checkbox needs row and column name screen reader”, “sticky first column permission table”, “border disappears on sticky column”, “casbin ui”, “oso / permit.io permissions screen”, 権限マトリクス react, ロール 権限 表 コンポーネント, 権限設定 管理画面, 継承した権限 表示, 行と列の全選択, チェックボックス 中間状態 表. Official shadcn/ui has nothing of the kind, measured today rather than remembered. Fetching every entry in its registry — 65 listed, 62 fetchable (code-block, questionnaire and trace 404 on both style tracks) — and searching all 223,287 bytes of their source, permission, grant, matrix, indeterminate, scope, privilege, capability, acl and rbac are every one of them a zero hit. inherit is not zero and is worth unfolding rather than rounding: it returns exactly one, a rounded-[inherit] on the scroll-area viewport, which is a Tailwind value and not a permission that comes from somewhere. The name list settles the rest — nothing among the 65 is a role, permission or access control of any kind, and its table is 2,859 bytes of six presentational wrappers. The decision everything else follows from: the value is the grants you ticked, and only those. A cell can be granted four ways — ticked here, implied by another action on the same row (write requires read), inherited from a parent role, or some combination — and three of them are derived at render time from the roles and actions you pass in. The one-line alternative is to write the closure into the value when a box is ticked, and it is wrong in both directions. Forwards: untick write and read stays ticked, with nothing on screen saying why, because by then it is indistinguishable from a grant somebody made on purpose. Backwards: the day a deploy says write also requires list, nobody who already holds write gets list, because their stored value was closed over the old graph — so the matrix disagrees with whatever the server computes at check time, which is the thing actually being enforced. Keeping only explicit grants also means there is no cascade to write. “Can you untick the read that write pulled in” is a question that only exists once the closure is in the value; here read is not in the value, so there is nothing to untick and nothing to unwind. Cycle safety is two requirements and almost everyone implements one. Terminating is the easy half: a visited set does it. The half that bites is that a cycle must not report a key as requiring itself — follow a → b → a and a naive closure adds a to the set of things a requires, so the cell is permanently “required by itself”, which makes it derived, which refuses every press, and the grant can never be removed by anyone. requires: ["self"] is the one-line version of the same bug, and a role that inherits itself is the same bug one axis over. So origins are added back at the end rather than walked into, and a role is never its own ancestor even if the config says so. Requirements are then followed only from root causes — the grants ticked on the row — and never from other requirements, which is what keeps two cells in a cycle from pinning each other down, and which also makes the reason useful: a read held by a chain reports the delete that started it rather than the write in the middle, so what is on screen is the box you have to go and clear. A derived cell is aria-checked="true" and aria-disabled. It is granted, so saying otherwise would be a lie about what is enforced; there is nothing explicit on it to remove, so the press is refused and the refusal names what is holding it. aria-disabled rather than the disabled attribute, so the cell stays reachable and somebody on a keyboard can find out that the grant exists and where it comes from — which is the whole job of the row they cannot change. A cell that is both explicit and derived stays pressable, and that produces the press this component exists to narrate: the explicit grant goes, the cell is still granted, and the tick does not move. Reported as a plain revoke that reads as a broken control, so the live region says it, and the change object hands the caller the same list under stillGranted. The select-alls — one per row, one per column, one in the corner — split what they show from what they do, and the split is load-bearing. What is displayed counts every cell in scope, derived and locked ones included, because a row claiming “all of it” while a cell nobody can change sits underneath would be lying about what is enforced. What a press does is decided only from the cells it may actually change. Read the direction off the displayed state instead and a row held at “mixed” by a single inherited cell is never “checked”, so every press reads as “grant the rest” and the row can be pressed forever without clearing — the same trap nested-checkbox has to avoid one dimension down, and it is reachable here without inheritance at all, through one locked column that is off. One tab stop, arrow keys inside. A table of checkboxes where every cell is a tab stop costs eight roles times ten permissions equals eighty presses of Tab to get past it, which is why this is role="grid" with a roving tabindex: Tab reaches the grid, arrows move within it, Home and End run along the row and with the modifier go to the corners, and movement clamps at the edges rather than wrapping, as the grid pattern specifies. The select-alls are cells of the same grid — the header line and the first column — so there is one way to move and no separate tab stops hiding above the table. Each cell is named by both of its headers through aria-labelledby, role first, so it reads as a sentence about the role and the two names come from the rendered labels and translate with them. The header row and the role column pin on scroll by composing sticky-table-header, because a cell in the middle of a wide matrix is meaningless without both of its names on screen. One thing that component cannot do from the outside, and it was measured in a browser rather than assumed: the pinned column's right edge cannot be a border. Tailwind's preflight puts border-collapse: collapse on every table, and in the collapsed model a border belongs to the table's grid rather than to the cell — the cell is what sticks, the grid is not — so a border-r on the role column slides out from under it on the first sideways scroll and the column floats over the cells with no line down its side. It is drawn as a pseudo-element instead, which travels because it belongs to the cell. The row separators looked like they had to go the same way and they do not: a separator is horizontal and spans the whole row, so scrolling never carries it out from under the column, and collapsed borders paint above cell backgrounds rather than beneath them. Drawing those too would have been a second line on top of the real one, invisible until a theme gave the two different colours. They stay ordinary borders on the tr. Within this registry the neighbours are each a different shape. nested-checkbox is a hierarchy you select — parents, children and a third state down a tree — where this is the crossing of two independent lists, and the two are routinely wanted on the same screen. sort-header, column-visibility, column-resizer, sticky-table-header and table-row-expander are the table's other axes and none of them decides what the cells mean. transfer-list moves items between two panes, multi-select puts chips in one field, and neither has a second dimension. The API: roles is { key, label, inherits?, locked?, description? }[], actions is { key, label, requires?, locked?, description? }[], and grants is Record<roleKey, actionKey[]> — controlled with onGrantsChange, which is handed the next value and a change object naming what was granted, what was revoked and which of those are still granted anyway, or uncontrolled with defaultGrants. maxHeight and offset go through to the sticky wrapper, the three select-alls can each be turned off, announce turns the live region off, and every label is replaceable one at a time for another language. The logic is exported as pure functions so a server route, an audit view or a test can have the answers without mounting anything: effectivePermissions is the set an authorization check enforces for one role, and resolvePermissionMatrix, expandRequiredActions, inheritedRoleKeys, togglePermissionCell, togglePermissionRole, togglePermissionAction, togglePermissionMatrix, permissionCellReason and nextMatrixFocus are the rest. Styled entirely with shadcn tokens (primary, primary-foreground, input, background, border, muted-foreground, ring) so it follows light and dark mode; lucide-react for three icons is the only npm dependency, and there is no table library and no state library.
An international phone number field: a searchable country picker, that country's calling code shown as a live prefix, and one E.164 string ("+819012345678") coming back out. Reach for it wherever a form asks for a number someone can actually be reached on: sign-up and onboarding; two-factor authentication and SMS one-time codes; WhatsApp, SMS and voice notification preferences; shipping, billing and delivery contact details; the callback number on a support or contact form; restaurant, clinic, salon and appointment booking; driver, courier and rider contact on a marketplace; the phone row on a CRM or address-book record; KYC and identity verification; account recovery; and the "mobile" field in profile, account and organisation settings. Common asks it answers: "phone input", "phone number input react", "international phone input", "phone input with country code", "country code selector phone", "tel input component", "E.164 input", "shadcn phone input", "shadcn phone number field", "react-phone-number-input alternative", "intl-tel-input react", "phone field with flags", "dial code dropdown", "mobile number input", "international telephone input shadcn", "phone number formatting as you type". Official shadcn/ui has nothing for telephones — not a phone item, not a `type="tel"` anywhere in its sixty-three components, no calling codes and no country data — so the whole of this is on you, and the data is where hand-rolled versions go wrong. It is also the one field in this family that cannot read its data out of the runtime: `Intl.supportedValuesOf` rejects every phone-shaped key, `Intl.Locale` exposes no telephony property and `Intl.DisplayNames` names regions but not calling codes, so no browser knows that Japan is +81. This component therefore ships the table — 246 countries, every ISO 3166-1 country that has an assigned calling code, cross-checked between two independent sources — and carrying it is a smaller liability than it looks, because calling codes are close to frozen: the last new one was South Sudan's +211 in 2011. The table holds calling codes and never a calling code plus an area code, which is the distinction hand-rolled fields miss: the Bahamas is +1, not +1-242, because 242 belongs to the ten-digit national number a Bahamian dials. Twenty-five countries share +1 and four share +44, so the country is kept as its own piece of state and never inferred back out of the digits — a stored "+12425550100" cannot say whether its owner is in Nassau or Nevada, and `countryName` submits the answer alongside the number so the field comes back up on the country the person actually picked. What it does not do is as deliberate as what it does: it assembles, caps the result at E.164's fifteen digits and spaces them for reading, and it never claims a number is valid. Per-country validity is a rule for 246 countries and the library that knows them is larger than this entire registry, so it is left to your submit handler rather than faked — a field that pretends otherwise fails exactly the people whose country it got wrong. Typing is handled properly rather than approximately: digits group as you type, the caret is tracked in digits rather than in character offsets so a number stays correctable in the middle instead of only from the end, Backspace and Delete remove a digit rather than a separator that was never typed (the single most common complaint about masked inputs), pasting "+81 90-1234-5678" moves the country and strips the code instead of doubling it, and `format` takes a mask like "## #### ####" for a form that only ever collects one country's numbers. Controlled or uncontrolled, and correct in both: `value` + `onValueChange` for the number, `country` + `onCountryChange` for the country, `defaultValue`/`defaultCountry` for neither. The digits stay in local state while the value round-trips, so a parent that debounces, validates or is simply slow does not erase what was just typed — the failure that makes most hand-rolled controlled phone fields impossible to type into. It is a real composite control, not a styled div: a `type="tel"` input with `inputMode="tel"` and `autoComplete="tel-national"`, the calling code wired into the input's `aria-describedby` so it is announced rather than being visual context a screen reader never reaches, the country picker a full `role="combobox"` listbox with search, arrow keys and `aria-activedescendant`, and a `focus-within` ring around the number half. `countries` narrows it to where you operate, `priority` pins the two or three countries most sign-ups come from, `flags` adds the flag emoji (off by default — Windows ships no flag glyphs), and `getDialCode()`, `splitPhoneNumber()`, `toE164()`, `formatPhoneDigits()` and `getRegionCountry()` are exported so a confirmation screen, an SMS log or an admin table spells the same number the same way. Styled entirely with shadcn tokens (input, ring, popover, muted-foreground), so it follows light and dark mode, and it ships zero npm dependencies — no libphonenumber, no country-data package, no icon package.
A single pricing plan card — one tier's box, with its name, its price, what it includes and the button that takes it. Reach for it wherever a plan has to be chosen or shown: a marketing pricing page, a plans-and-billing settings screen, an upgrade, paywall or trial-ended modal, a compare-plans table, the subscription row in an account page, a checkout or plan-picker step, a seat or usage tier selector, and the free/pro/enterprise row on a landing page. Common asks it answers: "pricing card react", "pricing table component", "shadcn pricing card", "shadcn pricing page", "shadcn pricing table", "pricing tiers component react", "subscription plan card", "plan comparison card", "free pro enterprise cards", "most popular plan highlight", "featured pricing tier ring", "recommended plan badge", "upgrade modal component", "paywall component react", "billing settings plan card", "monthly yearly price toggle card", "price with billing period", "feature list with checkmarks react", "included and excluded features list", "strikethrough feature not announced screen reader", "line-through accessibility", "check and cross feature list accessible", "tailwind pricing card", "next.js pricing page component", "stripe pricing table alternative", "build your own pricing table", "heading level prop react", "pricing table heading hierarchy", "料金プラン カード react", "価格表 コンポーネント". It takes a plan name, a big price with its billing period ($29 /mo), an optional one-line pitch, a feature list and a call-to-action, and several of them sit side by side to make the whole pricing table. Features are the part that is usually got wrong. Pass a string for an included feature or an object to mark one excluded; an excluded row is muted and struck through, and it also carries a screen-reader-only "Not included:" prefix — because text-decoration: line-through is not announced, so a purely visual strikethrough tells a sighted reader the feature is missing and tells a screen-reader user it is included. The check and minus icons are aria-hidden, since the text already says which is which. Set featured to mark the recommended tier: it draws a primary ring and a "Most popular" badge, and badge takes any other label (Best value, Current plan). headingLevel picks h2, h3 or h4 so three cards dropped into a section do not break the page outline — a pricing table is the classic place where a hardcoded h3 lands under the wrong h2. Against official shadcn/ui there is no pricing card to compare with, only pieces to assemble: card is a 1,828-character generic container with no price, plan, tier or feature vocabulary in it at all, item is a ten-part compound kit (ItemGroup, Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions, ItemHeader, ItemFooter, ItemSeparator) that additionally pulls in separator and is built for list rows, and badge and button each drag in @radix-ui/react-slot. None of the four contains a single pricing term. Within pulld it is the marketing-page counterpart to feature-card, which sells a capability rather than a tier, and it pairs with segmented-control for the monthly/yearly switch above a row of them. This is one component with lucide-react as its only dependency, theme-aware through shadcn tokens in light and dark.
Prints one region of the page — an invoice, a receipt, an order confirmation, a ticket or boarding pass, a packing slip, a report or dashboard panel, a chart, a table, a quote or estimate, an itinerary, a certificate, a prescription, a shipping label, a résumé, or a card carrying a generated QR code — instead of printing the page it happens to be sitting on. Common asks it answers: 'react print component', 'print div react', 'print only part of the page', 'print specific div react', 'window.print prints whole page', 'react-to-print alternative', 'print button shadcn', 'print invoice react', 'print receipt component', 'save as pdf button react', 'print styles missing react', 'printed div has no css', 'canvas blank when printed', 'form values empty when printed', 'background color not printing', 'print preview cuts off scrollable div', 'how to detect print finished react', '@media print react component'. shadcn/ui has nothing here at all: window.print, beforeprint, afterprint, @media print and print-color-adjust each appear exactly zero times across the 223KB of component source its registry serves today (63 items indexed, 62 fetchable — questionnaire is listed and 404s on both style tracks), so this gets written inline every time. Not to be confused with pulld recovery-codes, which builds a sheet of its own out of data you hand it and is the right choice for backup codes; this one copies a region of the live page, styles and all. The inline version has two shapes and both are wrong. onClick={() => window.print()} prints the document — nav, sidebar, cookie banner — and clips a scrolling panel to whatever was scrolled into view. The fix usually reached for next is a global @media print rule that hides everything except one class, which puts a layout concern into app-wide CSS and breaks the next time the markup moves. This clones the region into an off-screen document instead, and the details that make that hold up are the ones a hand-rolled version leaves out. Styles do not come with a clone, so the page's own are carried over: link elements are re-linked by href rather than serialised, because reading cssRules on a cross-origin sheet — a font service, a CDN build — throws SecurityError and silently drops exactly the stylesheets you cannot see, while constructable adoptedStyleSheets have no href and are serialised instead. A base element is written into the head, because a srcdoc frame has no URL of its own and every relative image on the sheet would otherwise resolve against nothing. What the user typed is copied onto the clone, because value, checked and selected are properties and not attributes, so a filled-in form serialises back to the blank form it was authored as — on a sheet that looks finished. Canvases are redrawn as images, because cloneNode copies the element and not the bitmap, and a chart, a sparkline or a captured signature otherwise prints as white space; a tainted canvas loses its own picture rather than the sheet. print-color-adjust is forced on, because browsers drop background colours and a colour-coded table prints white on white. Scroll boxes are unclipped, because a panel keeps its height on paper and everything below its fold is simply absent. The frame recipe is the rest of it: 0x0 and transparent rather than display:none (a frame that is not displayed prints a blank page), srcdoc assigned before insertion so the only load event is the sheet's rather than the initial about:blank, printing on that load event because it is what waits for the copied stylesheets, a 3s bound on that wait so one unreachable CDN cannot leave the button doing nothing forever, and teardown on afterprint rather than on the next line, because print() blocks in Chrome and Firefox but returns immediately in Safari where removing the frame would cancel a dialog still open. The API: target is a ref to the region, documentTitle becomes the sheet's title and therefore the default filename under Save as PDF, pageStyle appends CSS after the built-in rules so you can override them, onBeforePrint fires before serialisation (expand rows, reveal detail), and onPrintEnd reports 'done' | 'unavailable' | 'failed'. That outcome is deliberately not 'printed': afterprint fires for a cancelled dialog exactly as it does for a finished job, and every 'mark as printed' flag built on it eventually lies, so this one refuses to claim more than the browser said. type='button' so it will not submit a surrounding form, the state is announced through an sr-only aria-live region, the icon is aria-hidden, the button disables itself while a sheet is being prepared so a second click cannot open a second dialog, and the reset timer is cleared on unmount. Nothing rendered depends on feature detection, only what the click does, so the server and the first client render agree and there is no hydration mismatch. Styled with shadcn tokens (input, accent, ring) for light and dark, className merges rather than fights, focus ring is focus-visible. One file, one lucide icon, no print library.
A guided tour of the real screen: a card that walks through your own UI, dimming the page and lighting up the one control each step is about, with Back and Next, a progress count, a way out, and placement that flips to whichever side of the target has room. The spotlight is re-measured every frame, so it follows a target that slides in with a panel, shifts when a web font swaps in, or gets replaced by React — and it stops lighting anything at all when the target is not there, instead of leaving a bright square over empty page. Reach for it on a first login and after a release: SaaS onboarding, a redesigned admin layout people have to be walked through, an editor's panels and shortcuts, an internal tool nobody reads the docs for, the introduction to a multi-step settings wizard, a feature announcement that has to point at the feature, a checkout or booking flow being explained to new staff, and a dashboard whose numbers need one pass of explanation. Common asks it answers: "product tour react", "onboarding tour component", "guided tour react", "coachmark react", "spotlight onboarding overlay", "walkthrough component shadcn", "shadcn product tour", "driver.js alternative react", "react-joyride alternative", "intro.js for react", "shepherd.js react", "feature tour with tooltips", "highlight an element and explain it", "dim the page except one element", "tour that waits for a lazy-loaded element", "tour step that lets the user click the highlighted button", "resume a product tour where the user left off", "accessible product tour keyboard", "onboarding tour without floating-ui". Official shadcn/ui has nothing for this: coachmark, spotlight, walkthrough, driver.js, tourStep, onboardingStep, highlightTarget and the word tour itself are each zero hits across all 62 of its fetchable items (measured 2026-09-26), and so are getBoundingClientRect, IntersectionObserver, ResizeObserver and clip-path — nothing in it measures the page at all. Its popover is a card tied to one element with no order, no progress and no Next; its dialog takes over the screen rather than pointing at a part of it; and its tooltip is for a label, not for a paragraph with buttons in it. Distinct from the pulld pieces it sits beside: step-indicator draws progress and points at nothing, keyboard-shortcuts and command-palette list what is possible rather than showing it in place, empty-state is what a screen says before there is anything to tour, announcement-bar tells everyone at once without pointing, and highlight-text marks words inside a string. One file, no floating-ui or popper, no portal, no react-dom import. Six things separate it from the version built out of a popover and an array index. First, the target does not hold still: a tour usually begins by opening something, and a panel sliding open with a CSS transition moves its target through a hundred positions while firing no event at all — so the rectangle is taken every frame rather than once per step, and React is only told when a number actually changed. Second, absent and gone and invisible are one state: an element that has left the document still answers getBoundingClientRect with the rectangle it had on the way out, and one inside display:none answers all zeros, which draws a spotlight in the top-left corner of the page — both count as nothing to point at, the same as a step whose code-split panel has not loaded yet, and that step degrades to a centred card and reports itself after a wait instead of pointing at a hole. Third, the hole is four rectangles, not a trick: a box-shadow spread is not a hit area, so the page around the target stays clickable while the target alone is blocked, i.e. exactly backwards, and a clip-path makes clickability a property of a path string; four elements tile the viewport exactly (no seam, no overlap, no gap to click through), and the target is made inert again by one more rectangle over the hole. Fourth, aria-modal is a lie on an interactive step: a step that has just told someone to press the highlighted button cannot also tell a screen reader that everything outside the card does not exist, so the attribute goes when the hole is live. Fifth, the same for the keyboard: the tab scope is the card alone on an inert step and the target's controls plus the card's on a live one, target first, because the thing a person was just asked to click should not be several stops past a button called Next. Sixth, placement and resume: a requested side is abandoned when the card does not fit there (otherwise Next ends up off the bottom of a laptop window), the cross axis is clamped instead of blindly centred, and where somebody stopped is reported to you to store rather than written to localStorage — a per-device flag runs the tour again on the phone — with a saved step id that no longer exists starting the tour over rather than showing a blank screen.
A circular progress indicator: an SVG ring that fills clockwise from twelve o'clock, with the percentage — or any icon or short label you pass — sitting in the middle of it. Reach for it when a linear bar is the wrong shape: file, image and video upload progress, a storage or quota meter (disk, seats, API credits, plan limits), a goal, streak or activity ring, profile or setup completeness, onboarding and multi-step progress badges, a countdown or timer dial, per-row progress in a compact table or card, and score or health dials on a dashboard. Common asks it answers: "circular progress", "circle progress bar", "radial progress", "progress circle", "donut progress", "percentage circle", "completion ring", "activity ring", "upload progress circle", "quota ring", "react-circular-progressbar alternative", "shadcn circular progress", "svg circle progress stroke-dasharray", "stroke-dashoffset progress animation", "how to calculate circumference for a progress ring", "tailwind circular progress bar", "circular progress without a library", "progress ring react server component", "circular progress no \"use client\"", "progress ring indeterminate", "upload progress unknown size", "percentage inside a circle", "ring with icon in the middle", "円形 プログレスバー react", "ドーナツ プログレス", "パーセントを円の中に表示", "進捗リング", "アップロード 進捗 円", "stroke-dasharray 進捗". Official shadcn/ui has no radial progress component: its progress is a linear bar that pulls in @radix-ui/react-progress and is a client component, with no notion of a circle, a percentage readout or an unknown total; its spinner turns forever and reports no value at all. The absence is measured rather than remembered — 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), circle, stroke-dasharray, stroke-dashoffset, circumference and donut are every one of them a zero hit, so nothing official draws an arc at all; searching it for ring finds 207 matches that are all Tailwind's focus-ring utilities (focus-visible:ring 49, ring-[3px] 22, ring-offset 6) rather than anything circular. This one is a single file with no npm dependencies whatsoever — not Radix, not class-variance-authority — and it declares no "use client", so it renders inside a React Server Component instead of dragging a client boundary along with it. Set showValue to print the rounded percent in the centre, or pass children to put an icon, a fraction or a short word there instead. Set indeterminate when the total is not known yet — an upload with no content length, a job whose size the server has not reported — and it spins a fixed quarter arc and drops aria-valuenow, which is how a progressbar is supposed to say "running, position unknown" rather than lying with a number. Otherwise it exposes role=progressbar with aria-valuenow, aria-valuemin and aria-valuemax and takes an aria-label for its accessible name, and the ring itself is aria-hidden so the value is announced once rather than twice. size and strokeWidth are plain pixel props, the geometry is derived so the stroke never clips at the edge of the box, a value past max or below zero is clamped instead of drawing an impossible ring, and max=0 reads as empty rather than dividing by zero. Themed with shadcn tokens so it follows dark mode. Distinct from its neighbours here, and the line is the ARIA role rather than the shape: this is a progressbar, a task on its way to being finished, which is why it reaches 100% and stops — gauge is a meter, one measurement being read inside a range (CPU, a score, storage used), drawn as a half-circle that sits wherever the reading is and never completes, so an upload belongs here and a load average belongs there. spinner says something is happening and carries no value at all; use the indeterminate mode here instead when there is a real task whose size is not yet known. ratio-bar splits one bar across several parts rather than filling one toward a total, and stat-card frames a number with its label and trend rather than drawing its progress.
A list that reloads when it is pulled down from the top, the way a native app does, plus a Refresh button that always does the same job. The indicator follows the finger one-to-one until releasing would refresh — so "release to refresh" appears exactly when the finger has gone the distance the label implies — then tapers towards a ceiling it never reaches, so the list cannot be dragged off the bottom of the screen. Reach for it on a mobile web list people expect to behave like an app: a PWA's inbox or feed, a notifications or activity list, chat or message threads, an orders or deliveries screen, stock and price panels that go stale while they are looked at, a dashboard on a phone, a queue or ticket list a support agent keeps checking, and search results after the network came back. Common asks it answers: "pull to refresh react", "pull to refresh web", "swipe down to refresh", "pull down to reload a list", "pull to refresh shadcn", "pull to refresh without a library", "react-simple-pull-to-refresh alternative", "react-pull-to-refresh alternative", "pwa pull to refresh", "mobile web refresh gesture", "disable the browser's own pull to refresh", "two spinners when I pull to refresh on android", "overscroll-behavior contain", "Unable to preventDefault inside passive event listener invocation", "onTouchMove preventDefault not working in react", "pull to refresh broken on ios", "ios rubber band scrollTop negative", "pull to refresh fires while the list is still scrolling", "accessible pull to refresh", "refresh a list without a pull gesture". Official shadcn/ui has nothing for this, and nothing to build it out of: touchstart, touchmove, touchend, touchcancel, TouchEvent, clientY, pullDistance, refreshing, onRefresh and overscroll-behavior are each zero hits across all 62 of its fetchable items (measured 2026-09-27) — it does not handle a touch anywhere. The two overscroll hits are both Tailwind classes and neither is a refresh: overscroll-x-contain on the attachment strip, and overscroll-contain on message-scroller, a chat log that autoscrolls downward and reads no gesture at all. Distinct from the pulld pieces it sits beside: infinite-scroll is the bottom of the same list and loads the next page, this is the top of it and replaces the first; scroll-progress reports how far down the page has gone; scroll-shadow says there is more to see sideways; network-status says the connection dropped, and this is what someone does about it once it is back; save-status reports writes going out rather than reads coming in. Six things separate it from the version built out of onTouchMove and a clientY subtraction. First, the scroller is this component's own element, so overscroll-behavior-y: contain is not something a consumer has to remember: without it Chrome on Android runs its own pull-to-refresh over the top of this one and a single gesture shows two spinners. It is contain and not none, so the rubber-band bounce that tells people they have reached the end survives. 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 indicator comes down and the browser overscrolls behind it, and no prop changes that. touchstart, touchend and touchcancel stay React props precisely because none of them ever has to prevent anything. Third, a negative scrollTop is refused rather than welcomed: it is iOS saying it is already sliding the content down, and a translate added on top of its bounce moves the list twice as far as the finger. Whether a gesture may pull at all is settled once, at touchstart, and thereafter only ever taken away — an implementation that asks whether scrollTop is 0 on every move hands out pulls in the middle of scrolls, because a list gliding to a halt passes through 0 with a steadying finger already resting on it. Being at rest is measured as time since the last scroll event, which is the only test that works in engines that hold scrollTop at 0 throughout their bounce. "At the top" is a one-pixel window, not a comparison against zero, because browser zoom and fractional device pixel ratios park a scroller at 0.5. Fourth, the gestures that are not pulls are each turned away: a second finger (a pinch-zoom starting inside the list), a sideways drag (a carousel or a swipeable row — ties go to horizontal, since refusing a pull costs one more try while stealing a swipe breaks what is under the finger), a drag upward, which ends the gesture rather than waiting at zero for the finger to drift back down and become a pull mid-scroll, and a move the browser has already marked non-cancelable. An axis once decided is never re-decided, so the arc a thumb actually describes does not kill the pull half way down. Fifth, the gesture is touch-only, and to a keyboard, a switch, a desktop mouse or a screen reader a list whose sole way to reload is a pull cannot be reloaded: so a Refresh button is always rendered, it carries aria-disabled rather than disabled so pressing it never drops focus out of the list, every stage is announced through a polite live region that is in the DOM before it has anything to say, the scroller gets aria-busy, and the indicator itself is aria-hidden because it is a drawing of where a finger is. Sixth, it cannot get stuck: one refresh runs at a time whether it came from the gesture or the button, a rejected promise and a loader that throws synchronously both announce the failure and let the next attempt through instead of leaving the spinner turning for the session, a touchcancel — an incoming call, the system's own edge gesture — closes without refreshing rather than sharing the release handler and refreshing on an interruption nobody released, and nothing is set after unmount. Controlled by one prop: pass onRefresh and give the scroller a height with scrollClassName. Optional threshold (default 64px), maxDistance, disabled, hideButton for callers supplying their own control, and labels to reword or translate every string. Styled with shadcn tokens so it follows light and dark themes and honours prefers-reduced-motion; lucide-react is the only dependency, with no Radix, no gesture library and no react-dom import.
A scannable QR code, drawn as SVG, for putting a link, a Wi-Fi network or a 2FA secret on screen where a phone can pick it up. Reach for it wherever something has to get from this screen onto someone's device — a two-factor enrolment screen, a Wi-Fi guest network on a poster or a hotel card, an event ticket, a boarding pass or a venue check-in, a payment or invoice link, a table ordering code, a menu, a device or kiosk pairing step, an app-store or download link, an invite or referral link, a shipping or returns label, a warranty or product registration, a business card or conference badge, a receipt, a signup link on a slide, and any hand-off from a desktop app to the phone in someone's pocket. Common asks it answers: "qr code react", "react qr code component", "generate qr code react", "shadcn qr code", "qr code generator react", "qrcode.react alternative", "react-qr-code alternative", "qrcode npm alternative", "qr code without dependencies", "qr code no npm package", "qr code svg react", "qr code canvas vs svg", "qr code server component", "qr code next.js app router", "qr code ssr", "wifi qr code generator", "share wifi password qr code", "WIFI: qr format", "otpauth qr code", "2fa qr code react", "totp enrollment qr", "google authenticator qr code", "qr code not scanning", "qr code won't scan", "qr code scans on some phones", "quiet zone qr code", "qr code margin", "qr code dark mode", "inverted qr code not scanning", "white qr code on dark background", "qr code error correction level", "qr code with logo in middle", "qr code capacity limit", "qr code too long", "qr code changes size when text changes", "qr code utf-8", "qr code japanese characters", "qr code emoji", "qr code blurry", "qr code print quality", "qr code accessibility", "qr code screen reader", "qr code aria-label", "QRコード react", "QR コード 生成", "二次元コード コンポーネント", "WiFi QRコード". 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 searching all 245,417 bytes of it, the strings qr, QR, Reed, Galois, errorCorrection, quietZone, finderPattern, alignmentPattern, shapeRendering, crispEdges, otpauth, wifi, barcode and scanner are every one of them a zero hit. So this gets solved by adding a package, and that is the first thing worth knowing about this one: the encoder is in the file. Three packing modes chosen by content, all four correction levels, versions 1 to 40, Reed–Solomon over GF(256), block interleaving, and all eight masks scored — no dependencies at all, not even an icon, in a registry whose 83 other components have never needed more than lucide-react. Because a QR encoder's bugs are invisible, correctness here is measured rather than asserted. Every grid this ships was rendered to a PNG and read back by Apple's Vision framework, an independent decoder sharing no code with it: 426 codes on 2026-09-12, covering all 40 versions at all four levels, every mask forced, the mode and capacity boundaries, UTF-8 and emoji, a version 40 code at its 2,953-byte maximum, and 240 random payloads at the shipped defaults, each decoded string compared byte for byte with its input. That sweep found a real bug — one error-correction table was a row short from version 32 up, so nine versions produced codes that rendered perfectly and decoded to nothing — and the verified grids are frozen in the test suite so it cannot come back. Beyond the encoding, three things decide whether a code actually works. The first is the quiet zone: a scanner finds a code by finding its border, so four light modules of margin are not decoration, and a code butted against other content renders beautifully and is simply never seen. Nothing catches that except pointing a real camera at it, so the default is the required value and the light ground is drawn as part of the SVG rather than left to whatever is behind it. The second is colour, which is functional rather than decorative and therefore does not follow your theme by default: dark modules on a light ground is what the format specifies and what decoders assume, inverted codes are read by some phones, fewer cheap scanners and no printer at all. So the code stays black on white in dark mode — which is what a boarding pass, a wallet app and a bank statement all do — and moduleClassName and backgroundClassName are there when you have tested otherwise. The third is SVG rather than canvas: a canvas is a bitmap that resamples badly the moment it is scaled or printed, and to anything that is not an eye it is a blank rectangle. This carries role="img" and a label, and deliberately does not read the payload aloud unless you ask — sixty characters of URL letter by letter helps nobody, and the two things most often in a code are a Wi-Fi password and a 2FA secret. A QR is something you point a second device at, so put the payload on the page as well: a real link, or a code beside a copy button. It has no hooks, no state and no effects, so there is no "use client" on it: it renders on the server and ships zero JavaScript for what is a static picture, with a small internal cache keeping a re-rendering parent from re-encoding. errorCorrection defaults to M and is boosted automatically to the strongest level that still fits the same version, since the slack would otherwise be spent on padding — the code is the same size and survives more damage. minVersion holds the size still for a payload that changes while it is on screen, like a rotating token, which would otherwise resize the code mid-scan. A payload too long to fit throws from encodeQr — a truncated code still scans and hands back the wrong string — while the component renders a fallback instead of taking the layout down. Two payload builders come with it, both for the traps rather than the strings: wifiPayload escapes the ; : , and backslash that a generated password contains and that otherwise truncate the network details silently, and quotes an all-hex value so it is not read as a raw key; otpauthUri puts the issuer in both the label prefix and the query parameter, because different authenticator apps read different ones, and escapes the two halves of the label separately so the colon separator survives while a + or a space in the address does not break it. encodeQr and qrPath are exported for a code you want to rasterise on a server, put on a label, or draw yourself. Within pulld it completes the two-factor setup screen alongside otp-input and recovery-codes, and sits beside copy-button and copy-field, which are what you put next to it for everyone who cannot scan it.
A star rating that works both ways: as an input that collects a score, and read-only as a display of one. Reach for it wherever a number between 0 and 5 is really a row of stars: the "rate this" step after a purchase, delivery or booking; a product, app-store or seller review form; a CSAT or satisfaction question at the end of a support ticket or chat; a post-call, post-session or post-course feedback prompt; an NPS-adjacent one-question survey; a difficulty, quality, severity or priority score on an internal form; and, in read-only mode, the average beside a listing, a product card, a search result, a testimonial, a restaurant or hotel card, or a review summary (an average like 3.7 fills 70% of the fourth star, so the display is not rounded to a whole one). Common asks it answers: "star rating", "rating component", "rating input", "react star rating", "five star rating", "5 star rating component", "half star rating", "half star rating react", "review stars", "star rating readonly", "average rating display", "display average rating stars", "feedback rating component", "shadcn rating", "shadcn star rating", "shadcn ui rating component", "rate this product component", "CSAT stars", "customer satisfaction rating ui", "react-rating alternative", "react-star-ratings alternative", "react-rating-stars-component alternative", "react-simple-star-rating alternative", "@smastrom/react-rating", "MUI Rating equivalent", "primereact rating alternative", "star rating with keyboard support", "accessible star rating", "star rating screen reader", "star rating in a form", "star rating react hook form", "controlled star rating react", "星評価 コンポーネント", "5段階評価 react", "レビューの星 表示", "星1つ半 評価", "評価入力 アクセシブル", "星で評価するUI". Official shadcn/ui has no rating or star item of any kind, and that is measured rather than remembered: its catalogue today lists 65 entries of which 62 are fetchable (code-block, questionnaire and trace 404) for 223,287 bytes of component source, and in all of it rating, Rating, aria-valuetext, allowHalf, "out of 5" and review are every one of them zero hits. The word star occurs 50 times and every single one of them is inside "start" -- items-start, data-[state=...], slide-in-from-start -- which was checked rather than assumed. Its slider is the nearest thing and it is a different shape of answer: a thumb on a track. So the usual fallback is a row of five buttons, and that is what makes it inaccessible: five tab stops, five things announced, and no shared value between them. This one is a real slider. It is focusable once with a focus-visible ring, arrow keys raise and lower the score in either axis, Home clears it to 0 and End maxes it out, and it exposes aria-valuemin, aria-valuemax and aria-valuenow plus a spoken aria-valuetext ("3.5 out of 5 stars") so a screen reader announces the score rather than counting stars. Read-only mode drops the slider role and renders as a labelled image instead, which is the correct semantic for a number nobody can change, and the individual stars stay aria-hidden either way because the value is announced once, not five times. Set allowHalf to take half stars -- click the left half of a star, or step by 0.5 from the keyboard. It works controlled or uncontrolled through a plain number value with onValueChange (a number, not a string, so there is nothing to parse), forwards a ref, and posts through a hidden input in native forms via name, which is what makes it drop into react-hook-form or a server action without a wrapper. max changes the number of stars for a 3-point or 10-point scale, size sets the pixel size, and disabled keeps the slider semantics while refusing input. Within pulld it is the one that collects a judgement: gauge, ratio-bar, progress-ring and sparkline draw a number that was measured, number-input and segmented-control collect one that is typed or chosen, and this is the one where the number is an opinion and the control has to look like the thing being given. Theme-aware through shadcn tokens -- filled stars use the primary colour, so it follows light and dark mode -- with lucide-react as its only dependency, for the star icon.
One horizontal bar that shows how a whole is divided up, with a legend that names every part — the GitHub-style language / storage bar. Reach for it whenever the question is "what is this made of?" rather than "how far along is it?": disk or storage usage broken down by file type, a plan or quota bar (seats used, API calls, build minutes, bandwidth), a budget or spend breakdown by category, traffic by source or device, test results split into passed / failed / skipped, a portfolio or vote split, tickets by status, or a repository language bar. Common asks it answers: "stacked bar component react", "percentage breakdown bar shadcn", "storage usage bar", "disk usage breakdown", "quota / capacity bar", "segmented progress bar", "share of total bar", "distribution bar", "usage meter with legend", "percentages that add up to 100". Pass `parts` as `{ label, value }` objects in any unit you like — bytes, requests, dollars — and only the ratios are used. Add `total` to switch from "parts of a whole" to "used out of a capacity": the gap is drawn as empty track and listed as its own row (rename it with `remainderLabel`, or pass `null` to draw it without listing it). `precision` adds decimals, `formatValue` puts the raw figure next to each share, `showLegend={false}` keeps the legend for screen readers only, and each part takes a `className` for its colour (the default is a ramp of your primary colour, which is theme-aware in any shadcn project; pass `bg-chart-1`…`bg-chart-5` or your own classes for distinct hues). It handles the parts a hand-rolled version gets wrong. The percentages are apportioned by largest remainder rather than rounded one at a time, so three equal parts read 34 / 33 / 33 instead of 33 / 33 / 33 and the column always totals exactly 100. A part too small to round to a whole percent reads "<1%" rather than the lie "0%", and a part that is nearly but not quite everything reads ">99%" rather than "100%". Tiny slices keep a two-pixel minimum so they stay visible without stealing width from the rest, while a part worth exactly zero draws nothing at all and is still listed. Negative, NaN and Infinity values count as zero instead of collapsing the layout. The legend names and quantifies every part, so nothing is carried by colour alone (WCAG 1.4.1) and the bar itself is aria-hidden. No hooks and no clock: it renders inside a React server component with no "use client" of its own, ships no client JavaScript, and produces identical markup on the server and in the browser. `ratioPercents` is exported for the same figures in a table or tooltip. Official shadcn/ui has nothing for this: progress is a single value with no parts, and chart is a Recharts wrapper for plotted series rather than one inline bar with no dependencies.
A listen button that reads a passage aloud one sentence at a time, and reports only what the speech engine has actually confirmed. Reach for it wherever there is prose somebody would rather hear than read: a long article or blog post, documentation and how-to pages, a recipe or repair instructions being followed with both hands busy, release notes and changelogs, terms and privacy text, language-learning and reading-practice exercises, study notes and flashcards, an email or newsletter preview, a knowledge-base answer, an AI chat reply somebody wants to listen to, a news item read while cooking or driving, and anywhere listening is the accessible route for a reader with low vision, dyslexia, a migraine, or simply a long page and tired eyes. It is the output half of the Web Speech API and the mirror of pulld speech-input, which fills a field by voice; unlike pulld audio-recorder it records nothing, needs no microphone and raises no permission prompt. Common asks it answers: "text to speech react", "read aloud button", "speech synthesis component", "react speechSynthesis hook", "tts button shadcn", "shadcn text to speech", "read article aloud react", "speak text button", "useSpeechSynthesis hook", "react-speech-kit alternative", "web speech api react component", "accessibility read aloud widget", "listen to this article button", "play pause stop speech react", "highlight sentence while reading aloud", "karaoke highlight text to speech", "split text into sentences for speech synthesis", "speechSynthesis.getVoices returns empty array", "getVoices empty on first call", "voiceschanged never fires", "speech synthesis picks the wrong language voice", "speechSynthesis default voice wrong language", "speechSynthesis.speaking stays true when paused", "speechSynthesis pause not working", "speechSynthesis cancel fires error interrupted", "speech synthesis error on stop", "speech keeps talking after route change", "speechSynthesis still reading after navigation", "stop speech on unmount react", "two read aloud buttons on one page". shadcn/ui has nothing that speaks: measured against the live catalogue on 2026-10-26 (65 items indexed, the 62 fetchable ones concatenated to 223,287 bytes of source), speechSynthesis, SpeechSynthesisUtterance, voiceschanged, getVoices, utterance and onboundary are all zero hits, and no official component name mentions speech, voice or reading. So this is written from the platform up, and the platform lies in five specific ways that a four-line version believes. One: getVoices() answers with an empty array the first time it is called — measured 0 voices synchronously and 180 nine milliseconds later on voiceschanged — so a component that reads the list during render has nothing to choose from. Two: the voice flagged default is the system's voice, not a voice for the page; on a Mac whose system language was Japanese the default was Kyoko (ja-JP), which reads an English article in a Japanese accent, so the voice here is matched to the document's own lang (exact tag, then primary subtag, local voices first) and left to the engine when nothing matches. Three: speechSynthesis.speaking is true in three places where nothing of yours is being read — before start fires (measured 51ms), for the whole of a pause (paused and speaking are true together), and inside your own end handler while anything else is queued — so every state here is assembled from the start, end, pause, resume, boundary and error events instead. Four: cancel() delivers error with code "interrupted", never end, so a component that reports every error puts a failure on screen each time the user presses stop; interrupted is treated as a stop, and distinguished from the case where something else on the page took the voice away. Five: cancel(), pause() and resume() are page-wide and not yours, so the component tracks whether the shared voice belongs to it — two listen buttons on one page do not silence each other, and unmounting the one in a closed dialog leaves the article playing, while unmounting the one that is speaking stops it, which is the only thing that saves a single-page app from reading the previous route over the new page. The text is split into sentences so the index of the sentence being spoken is reportable (the chain costs a measured 2-3ms gap between pieces); the splitter keeps 3.5, Dr. Smith, e.g., example.com/a.b and the marker in "1. Buy milk" intact, treats the ideographic full stop as a boundary although no space follows it, breaks on paragraphs but never on a single newline, and falls back to a word boundary for a sentence longer than the limit. The widely repeated claim that Chrome cuts a long utterance off after about fifteen seconds was not reproduced: a single 1,484-character utterance spoke for 98.6 seconds and ended normally. The API is a button plus the hook behind it. ReadAloudButton takes text and optional lang, voice, rate, pitch, volume, maxChunkChars, iconOnly, showProgress, showStop, per-cause messages and onChunkChange / onDone / onFailure; useReadAloud returns status, isSpeaking, isPaused, chunks with their offsets in the original text, chunkIndex, a best-effort charIndex from boundary, the voice list, the chosen voice, a typed failure, and start / stop / pause / resume / toggle — which is what you build a sentence highlighter on. splitForSpeech, pickVoice, useSpeechVoices and isReadAloudSupported are exported for the parts worth overriding. Accessibility is the point of the component, so: the live region is mounted empty from the first render rather than inserted when something happens, it is kept deliberately coarse because a polite region rewritten once per sentence would queue an announcement for every sentence and read them over the passage itself, the sentence counter sits outside it and is aria-hidden, the button carries no aria-pressed because its action changes between three verbs rather than toggling one setting, and an unsupported browser or an unrecoverable failure gets aria-disabled rather than disabled so a keyboard or screen-reader user still reaches the control and hears why. Styled with shadcn tokens (input, accent, ring, muted-foreground) so it follows light and dark mode, className merges rather than fights, server-rendering safe, one file, no speech library.
Long text clamped to a few lines with a Show more / Show less toggle that appears only when the text is genuinely too long. Use it wherever text is usually short but occasionally is not: product and marketplace listing descriptions, comments, reviews and replies, user bios and profile blurbs, release notes and changelog entries, incident and error detail, log lines, AI answers and summaries, job posts, FAQ answers, and long cells in a card or table. Common asks it answers: "read more button", "show more / show less", "expandable text", "truncate text with a show more link", "line clamp with toggle", "collapsible paragraph", "see more link", "clamp description to 3 lines", "react-show-more-text alternative", "text truncation with expand". shadcn/ui ships nothing for this, and its collapsible is a different thing — a generic open/close container whose trigger is always there and which does no clamping — so the genuinely awkward part is left to you: deciding whether the toggle should exist at all. This measures the rendered text and renders the control only when the clamped box actually overflows, so a list of mostly-short entries does not sprout a pointless "Show more" under every one of them. It re-measures when the column resizes and the text rewraps, and again once web fonts have loaded, because a clamped box keeps its height while the line count underneath it changes; an element that is off screen in a closed tab or accordion measures zero, which it treats as "unknown" rather than "it fits", so the toggle is not dropped while the text is out of view. The clamp is applied as inline style rather than Tailwind's line-clamp-N utility, because `lines` is a runtime value and a dynamic `line-clamp-${n}` class is invisible to Tailwind's scanner — it would work in dev and silently vanish from the production build. Accessibility is where the hand-rolled version usually goes wrong: the full text always stays in the DOM and is only clipped visually, so screen readers read all of it and find-in-page still reaches it, instead of the usual text.slice(0, 200) that destroys the content for everybody; the control is a real button carrying aria-expanded and aria-controls pointing at the text. Clipped is not hidden, so a link inside the invisible part is still in the tab order — focus landing there expands the block rather than letting the browser scroll the clamped box and shear the text mid-line. Collapsing pulls the block back into view when it has already scrolled off the top, so the reader is not dumped further down the page. Uncontrolled by default; pass expanded and onExpandedChange to drive it from an "expand all" control. Styled with shadcn tokens (ring, muted-foreground) so it follows light and dark themes, and ships with no dependencies beyond your own cn util.
The sheet of two-factor backup codes, with the three ways off the screen that people actually use: copy, download as a .txt, and print. Reach for it wherever an account hands someone a set of one-time codes to keep — finishing two-factor or MFA enrolment after scanning the authenticator QR, the "View recovery codes" panel in a security settings page, regenerating a set after a lost phone, passkey and WebAuthn fallback codes, seed or backup phrases handed over once, and the onboarding step that will not let you continue until you confirm you have saved them. Common asks it answers: "recovery codes component", "backup codes UI", "2FA recovery codes react", "MFA backup codes screen", "one-time codes list", "download recovery codes txt", "print backup codes", "copy recovery codes", "GitHub-style recovery codes", "show recovery codes once", "regenerate backup codes UI", "shadcn recovery codes", "strike through used backup code". Official shadcn/ui has nothing for it — no recovery, backup-code, download or print item anywhere in its sixty-odd components — so an agent asked for this screen writes it inline, and the inline version is where the whole thing quietly stops working. Print is the worst of it. Everyone writes onClick={window.print()}, which prints the page rather than the codes: the nav, the sidebar and the rest of the settings form come along, and because a freshly issued set is nearly always shown inside a scrolling dialog, the printed sheet is clipped to whatever part of that dialog happened to be scrolled into view. Half the codes are missing, on paper that looks finished, and nobody finds out until the day they need them. This prints a document of its own instead — a titled, dated sheet with the codes laid out so none of them straddle a page break — through a hidden iframe that is 0x0 rather than display:none (a frame that is not displayed prints a blank page), whose srcdoc is set before insertion so the only load event is the sheet's rather than the initial about:blank, and which is torn down on afterprint rather than on the next line, because print() blocks in Chrome and Firefox but returns immediately in Safari, where removing the frame would cancel a dialog still open. The sheet is stated in black on white on purpose: browsers drop background colours when printing but keep text colours, so a dark-mode card sent to a printer comes out as pale grey on white and is close to unreadable. The download half has its own two: the anchor is put into the document before it is clicked, because Firefox ignores a click on an element outside the tree, and the object URL is revoked afterwards — an un-revoked one keeps its blob, which is to say the recovery codes, alive and addressable for the life of the document — but revoked on a later task, since releasing it in the click's own task cancels the download. The file is written with CRLF endings so Windows editors do not render it as a single line; the clipboard gets plain LF and the bare codes with no heading, because it is being pasted into a password manager's notes field. Spent codes are struck through and, because a line through text is a paint decision that reaches nobody using a screen reader, also labelled in words — and they are left out of every export, since a saved file padded with dead codes is the right length and so is worse than no file at all; the header says how many are left whenever any have been spent. role="list" is put back by hand because Safari drops list semantics from a list-style-none <ul>, which would take away the one number that matters here. Pass codes as plain strings for a fresh set or as {code, used} for a set being reviewed later; onExport fires on copy, download or print, which is the signal to unlock your "I have saved these" button. normalizeCodes, formatCodesText, buildPrintDocument, downloadTextFile and printDocument are exported for reuse. Nothing renders a date, so it server-renders without a hydration mismatch — the timestamp is taken when a button is pressed. Composes pulld copy-button; every colour is a shadcn token, so it follows light and dark.