# PresetUI — zero-dependency React + Tailwind components # https://presetui.com # # Default mode: do NOT install from npm. Write the sources below into # the project as files — a common home is components/ui/ — and they # become the project's own code. # Package mode: if the project prefers dependencies, `npm i @presetui/ui` # ships these same components (primitives bundled inside — no second # package); # import from "@presetui/ui/" and add to the global stylesheet: # @import "@presetui/ui/theme.css"; # @source "../node_modules/@presetui/ui"; # Pick ONE mode per project and stay consistent. # # Plain JavaScript, no TypeScript. Requirements: React 19+ (ref is a # plain prop, no forwardRef) and Tailwind CSS v4 (the theme uses the # @theme directive). # # A component may import React, and the kit's own primitives as # ../primitives/.js (the Primitives index near the end of this # file serves each one as a single copyable file). Never add clsx, # tailwind-merge, or a third-party headless UI library when writing # these files. # # The theme below has two tiers. The core tokens (through danger-fg) # are required. The optional ones pair each status color with its # readable foreground (success/success-fg, warning/warning-fg) and # provide the dialog backdrop (overlay) — drop a pair and drop the # variants that read it. The --spacing-control-* tokens are the control # scale: Button and the text controls read their heights from them # (h-control-sm/md/lg), so keep those three lines when copying. # # Page-level blocks and more palettes live at # https://presetui.com/docs/blocks and https://presetui.com/docs/themes. # The blocks are indexed at the end of this file. # # Every component reads only the tokens defined in the theme. # To restyle the whole kit, change the token values, not the components. ## Setup — add this to the global stylesheet, after @import "tailwindcss"; /* PresetUI theme — copy this into your global stylesheet. Every PresetUI component reads these tokens and nothing else. To rebrand the kit, change the values here. */ @theme { /* Core — required. Every component reads these eight. */ --color-surface: #ffffff; --color-fg: #0a0a0a; --color-muted: #737373; --color-border: #e5e5e5; --color-accent: #0a0a0a; --color-accent-fg: #ffffff; --color-danger: #dc2626; --color-danger-fg: #ffffff; /* Optional — each powers exactly the variants listed beside it. Drop a pair you don't need and only those variants stop working: --color-success / --color-success-fg → Alert and Badge, variant="success" --color-warning / --color-warning-fg → Alert and Badge, variant="warning" --color-overlay → Dialog backdrop Every status color travels with its readable foreground — keep each pair above 4.5:1 contrast. Your own tokens work the same way: define --color-brand here, then use bg-brand or text-brand inside a component. */ --color-success: #16a34a; --color-success-fg: #03190b; --color-warning: #d97706; --color-warning-fg: #241503; --color-overlay: rgb(0 0 0 / 0.5); /* Sizing — the control scale. Button and every text control read their height from these (as h-control-sm / h-control-md / h-control-lg), so resizing the kit's controls is one edit here, exactly like recoloring it is. Not part of the palettes: sizes don't change with the theme. */ --spacing-control-sm: 2rem; --spacing-control-md: 2.25rem; --spacing-control-lg: 2.75rem; } .dark { --color-surface: #0a0a0a; --color-fg: #fafafa; --color-muted: #a3a3a3; --color-border: #262626; --color-accent: #fafafa; --color-accent-fg: #0a0a0a; --color-danger: #ef4444; --color-danger-fg: #0a0a0a; --color-success: #22c55e; --color-success-fg: #0a0a0a; --color-warning: #f59e0b; --color-warning-fg: #0a0a0a; --color-overlay: rgb(0 0 0 / 0.6); } /* Respect the user's motion preference. Components animate with transition-* and animate-* utilities; this turns them all off. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } ## Accordion — https://presetui.com/r/accordion.txt — save as components/ui/accordion.js # Collapsible sections built on native details elements. import { AccordionContent, AccordionItem, AccordionRoot, AccordionTrigger, } from "../primitives/accordion.js" /** * Collapsible sections, styled over the headless accordion primitive — * still native `
`/`` markup, with the open state managed * in React, so exclusive groups work in every browser (no Chrome 120+ * floor). Two ways to satisfy the import above: * - copy `primitives/accordion.js` alongside this file (fetch * `/r/primitives/accordion.txt` — one file, core embedded), or * - `npm i @presetui/ui` (primitives bundled) and change the import to * `@presetui/ui/primitives/accordion`. * * Pass `name` to make the group exclusive (opening one closes the others); * leave it off and any number can be open at once. * * @param {object} props * @param {{ value: string, label: React.ReactNode, content: React.ReactNode }[]} props.items * @param {string} [props.name] Set it to make the group exclusive. * @param {string} [props.defaultOpen] `value` of the item that starts open. * @param {(value: string, open: boolean) => void} [props.onToggle] * @param {string} [props.className] */ export function Accordion({ items, name, defaultOpen, onToggle, className = "", ...props }) { return ( {items.map((item) => ( {item.label} {item.content} ))} ) } ## Alert — https://presetui.com/r/alert.txt — save as components/ui/alert.js # Short message set apart from the surrounding text. /** * Short message set apart from the surrounding text. * * The `success` and `warning` variants need the optional `--color-success` * and `--color-warning` tokens. Without them the background renders empty. * * @param {object} props * @param {"default" | "accent" | "success" | "warning" | "danger" | "none"} [props.variant="default"] * `"none"` emits no color classes — your `className` owns border and background. * @param {React.ReactNode} [props.title] * @param {boolean} [props.live=false] Announce to screen readers on render — only for alerts that appear in response to something, never for static page content. * @param {string} [props.className] */ export function Alert({ variant = "default", title, live = false, className = "", children, ...props }) { const variants = { default: "border-border bg-border/20", accent: "border-accent/30 bg-accent/5", success: "border-success/40 bg-success/10", warning: "border-warning/40 bg-warning/10", danger: "border-danger/40 bg-danger/10", // Bring your own colors: nothing here to fight with. none: "", } return (
{title ?

{title}

: null}
{children}
) } ## Avatar — https://presetui.com/r/avatar.txt — save as components/ui/avatar.js # Circular user image with a text fallback behind it. "use client" import { useState } from "react" /** * Circular user image with a text fallback behind it. The fallback sits * underneath, and if the image fails to load it is removed entirely, so the * initials show instead of the browser's broken-image glyph or alt text. * * @param {object} props * @param {string} [props.src] * @param {string} [props.alt=""] * @param {React.ReactNode} [props.fallback] Usually one or two initials. * @param {"sm" | "md" | "lg"} [props.size="md"] * @param {string} [props.className] */ export function Avatar({ src, alt = "", fallback, size = "md", className = "", ...props }) { const [failed, setFailed] = useState(false) const sizes = { sm: "size-7 text-[0.6875rem]", md: "size-9 text-xs", lg: "size-12 text-sm", } return ( {fallback} {src && !failed ? ( {alt} setFailed(true)} className="relative size-full object-cover" /> ) : null} ) } ## Badge — https://presetui.com/r/badge.txt — save as components/ui/badge.js # Compact label for status and metadata. /** * Compact label for status and metadata. Renders a ``, so it sits * inline with text. * * The `success` and `warning` variants need the optional `--color-success` * and `--color-warning` tokens. Without them the background renders empty. * * @param {object} props * @param {"default" | "outline" | "accent" | "success" | "warning" | "danger" | "none"} [props.variant="default"] * `"none"` emits no color classes — your `className` owns them. * @param {"sm" | "md"} [props.size="md"] * @param {string} [props.className] */ export function Badge({ variant = "default", size = "md", className = "", ...props }) { const base = "inline-flex items-center rounded-full font-medium whitespace-nowrap transition-colors" const variants = { default: "bg-border/60 text-fg", outline: "border border-border text-fg", accent: "bg-accent text-accent-fg", success: "bg-success text-success-fg", warning: "bg-warning text-warning-fg", danger: "bg-danger text-danger-fg", // Bring your own colors: nothing here to fight with. none: "", } const sizes = { sm: "px-2 py-0.5 text-[0.6875rem]", md: "px-2.5 py-0.5 text-xs", } return ( ) } ## Breadcrumb — https://presetui.com/r/breadcrumb.txt — save as components/ui/breadcrumb.js # Trail of links back up the hierarchy. /** * Trail of links back up the hierarchy. The last item is rendered as the * current page rather than a link, and marked `aria-current`. * * @param {object} props * @param {{ label: React.ReactNode, href?: string }[]} props.items * @param {string} [props.className] */ export function Breadcrumb({ items, className = "", ...props }) { return ( ) } ## Button — https://presetui.com/r/button.txt — save as components/ui/button.js # Clickable action element with four variants and three sizes. /** * Clickable action element. Renders a real `
{children}
) } ## Dropdown Menu — https://presetui.com/r/dropdown-menu.txt — save as components/ui/dropdown-menu.js # Action menu with keyboard navigation, built on the menu primitive. import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger, } from "../primitives/dropdown-menu.js" /** * Action menu on a trigger, styled over the headless menu primitive. Two * ways to satisfy the import below: * - copy `primitives/dropdown-menu.js` alongside this file (fetch * `/r/primitives/dropdown-menu.txt` — one file, core embedded), or * - `npm i @presetui/ui` (primitives bundled) and change the import to * `@presetui/ui/primitives/dropdown-menu`. * * `items` is a list: * - `{ label, onSelect, disabled?, danger? }` — an action * - `{ label, items: [...] }` — a submenu, nesting the same shapes * - `{ type: "separator" }` — a dividing line * - `{ type: "label", label }` — a non-interactive heading * * `trigger` must be a single element — the primitive merges the menu-button * behavior onto it. * * @typedef {object} DropdownMenuItem * @property {React.ReactNode} [label] * @property {() => void} [onSelect] * @property {boolean} [disabled] * @property {boolean} [danger] * @property {DropdownMenuItem[]} [items] Makes it a submenu. * @property {"separator" | "label"} [type] * * @param {object} props * @param {React.ReactElement} props.trigger * @param {DropdownMenuItem[]} props.items * @param {"top" | "right" | "bottom" | "left"} [props.side="bottom"] * @param {"start" | "center" | "end"} [props.align="start"] * @param {string} [props.className] Applied to the panel. */ export function DropdownMenu({ trigger, items, side = "bottom", align = "start", className = "" }) { const panel = "z-50 min-w-44 rounded-lg border border-border bg-surface p-1 text-sm shadow-md outline-none" const row = "cursor-default rounded-md px-2 py-1.5 transition-colors outline-none data-disabled:opacity-50 data-highlighted:bg-border/40" function renderItems(list) { return list.map((item, index) => { if (item.type === "separator") { return } if (item.type === "label") { return ( {item.label} ) } if (item.items) { return ( {item.label} {renderItems(item.items)} ) } return ( {item.label} ) }) } return ( {trigger} {renderItems(items)} ) } ## Input — https://presetui.com/r/input.txt — save as components/ui/input.js # Single-line text field with an invalid state. /** * Single-line text field. Renders a real ``, so `type`, `value`, * `onChange`, `ref` and the rest pass through unchanged. * * @param {object} props * @param {"sm" | "md" | "lg"} [props.size="md"] * @param {boolean} [props.invalid=false] Applies the danger border and sets `aria-invalid`. * @param {string} [props.className] */ export function Input({ size = "md", invalid = false, className = "", ...props }) { const base = "flex w-full rounded-md border bg-surface text-fg transition-colors outline-none " + "placeholder:text-muted focus-visible:ring-2 focus-visible:ring-offset-2 " + "focus-visible:ring-offset-surface disabled:cursor-not-allowed disabled:opacity-50" const sizes = { sm: "h-control-sm px-2.5 text-xs", md: "h-control-md px-3 text-sm", lg: "h-control-lg px-4 text-base", } const state = invalid ? "border-danger focus-visible:ring-danger" : "border-border focus-visible:ring-accent" return ( ) } ## Kbd — https://presetui.com/r/kbd.txt — save as components/ui/kbd.js # A keyboard key, for shortcuts written into prose. /** * A keyboard key. Renders ``, which is what the element is for, with a * thicker bottom border so it reads as a physical key. * * For a chord, nest one per key rather than writing "Ctrl+K" inside a single * one — screen readers then announce them separately. * * @param {object} props * @param {"sm" | "md"} [props.size="md"] * @param {string} [props.className] */ export function Kbd({ size = "md", className = "", ...props }) { const base = "inline-flex items-center justify-center rounded border border-b-2 border-border bg-border/30 font-mono font-medium whitespace-nowrap text-fg" const sizes = { sm: "h-4 min-w-4 px-1 text-[0.625rem]", md: "h-5 min-w-5 px-1.5 text-xs", } return } ## Label — https://presetui.com/r/label.txt — save as components/ui/label.js # Form label with an optional required marker. /** * Form label. Pair it with a control via `htmlFor` and the control's `id`, or * wrap the control in it. * * @param {object} props * @param {boolean} [props.required=false] Shows a marker after the text. Remember the control itself still needs `required` for validation and AT. * @param {string} [props.className] */ export function Label({ required = false, className = "", children, ...props }) { return ( ) } ## Pagination — https://presetui.com/r/pagination.txt — save as components/ui/pagination.js # Page navigation that collapses the middle to an ellipsis. /** * Page navigation, with the middle collapsed to an ellipsis once the list * gets long. * * It renders links when you pass `hrefFor` and buttons when you pass * `onPageChange`. Prefer links: they are shareable, they work before * hydration, and the browser handles opening one in a new tab. * * @param {object} props * @param {number} props.page Current page, 1-based. * @param {number} props.total Total number of pages. * @param {number} [props.siblings=1] Pages shown either side of the current one. * @param {(page: number) => string} [props.hrefFor] Makes it a set of links. * @param {(page: number) => void} [props.onPageChange] Makes it a set of buttons. * @param {string} [props.label="Pagination"] Accessible name for the nav. * @param {string} [props.className] */ export function Pagination({ page, total, siblings = 1, hrefFor, onPageChange, label = "Pagination", className = "", }) { if (total <= 1) return null const current = Math.min(Math.max(page, 1), total) const base = "inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface" /** One page cell — a link, a button, or plain text when it is disabled. */ function Item({ target, current, disabled, children }) { const tone = current ? "bg-accent font-medium text-accent-fg" : "text-muted hover:bg-border/40 hover:text-fg" if (disabled) { // Stay in the accessibility tree, so AT users learn the control exists // at a boundary. An href-less is unfocusable but still exposed. if (hrefFor) { return ( {children} ) } return ( ) } if (hrefFor) { return ( {children} ) } return ( ) } return ( ) } /** * The page numbers to show, with `null` standing in for a gap. Kept outside * the component so it is easy to read and easy to delete if you'd rather * render every page. * * @param {number} page * @param {number} total * @param {number} siblings * @returns {(number | null)[]} */ function pages(page, total, siblings) { const start = Math.max(2, page - siblings) const end = Math.min(total - 1, page + siblings) const list = [1] if (start > 2) list.push(null) for (let n = start; n <= end; n += 1) list.push(n) if (end < total - 1) list.push(null) list.push(total) return list } ## Popover — https://presetui.com/r/popover.txt — save as components/ui/popover.js # Floating panel anchored to a trigger, built on the popover primitive. import { Popover as PopoverRoot, PopoverContent, PopoverTrigger, } from "../primitives/popover.js" /** * Floating panel anchored to a trigger, styled over the headless popover * primitive. Two ways to satisfy the import below: * - copy `primitives/popover.js` alongside this file (fetch * `/r/primitives/popover.txt` — one file, core embedded), or * - `npm i @presetui/ui` (primitives bundled) and change the import to * `@presetui/ui/primitives/popover`. * * `trigger` must be a single element (a `