@hackcove/ui-react
v0.10.0
Published
Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a theme from @hackcove/themes.
Maintainers
Readme
@hackcove/ui-react
Hack Cove React components, written against Tailwind v4 utilities. They carry no theme: pair them
with a theme from @hackcove/themes, such as @hackcove/themes/catppuccin-mocha.css, and put its scope
class on <html> (.dark for Mocha). With no theme they render in Tailwind's own default colours,
a plain light look; it works, but it is not a look Hack Cove designs or checks.
Status: 0.x, published to npm. License: MIT.
Install
bun add @hackcove/ui-react
# peers: react >= 18, react-dom >= 18, tailwindcss ^4
# optional type peers: @types/react, @types/react-dom (install them in a TypeScript app)One runtime dependency comes with it: react-aria-components (and @internationalized/date),
for DateField's calendar. It brings no CSS; the classes are ours.
Tailwind skips node_modules, so tell it to scan the build for the utilities the components use,
in the stylesheet that imports Tailwind (the path is relative to that file):
@import "tailwindcss";
@source "../node_modules/@hackcove/ui-react/dist";Use
import { Card, PageHeading, Pill, Text } from "@hackcove/ui-react";
export const Example = () => (
<Card title="Status" count={2}>
<PageHeading>All good</PageHeading>
<Text>Two things shipped.</Text> <Pill tone="emerald">ok</Pill>
</Card>
);No styling props
No component takes className or style, as an own prop or through the HTML props it spreads
(data-*, aria-*, id, title and event props still pass). Badge pins to a corner with
corner, ListRow draws its line with divided; a look the component lacks is a prop or variant
to propose, not a class to add. A router link is drawn through render (see Router links), never
by copying a component's classes.
Every exported component passes data-* and aria-* through to its root element (an e2e hook such as
data-testid, or aria-describedby), so there is no need to wrap one in an element of your own to
carry them; passthrough.test.tsx fails for an export that does not. A component with no single
root (DateField) puts data-* on its wrapper and aria-describedby on the field.
Sizes
One scale runs through the library: Size is "sm" | "md" | "lg", and md is the default
wherever a component takes size. The controls (Button (also as a link), IconButton,
ColourSchemeToggle, Input (also type="search"), Select, Textarea, Checkbox, Switch, RadioGroup,
DateField (also with calendar), SegmentedControl, Tabs variant="pill")
share heights at each step (sm 26px, md 38px, lg 46px, the same on a Select and a DateField as on an Input; a Playwright check measures them side by side in every theme), so a row of mixed controls at one size lines up. Card, ListRow,
FileDrop, Dialog, Drawer, Avatar, SubsectionHeading, TextLink and the bars take the
same three names. Only Avatar (xs, xl, 2xl, 3xl) and the Stack gap (xs, xl) go beyond them.
One default is not md, on purpose: Cluster's gap is xs (a row is a label and its chips,
which sit tight). TextLink with no size
takes the size of the text around it. Upgrading: MIGRATING.md, shipped in the package (node_modules/@hackcove/ui-react/MIGRATING.md).
What is exported
- Primitives:
Card(a bordered panel;titleandcountmake a small heading,titleStyle="name"sets the title in sentence case in the primary ink for a card that is a named thing (the defaultoverlineis small uppercase), andstatus(aPillor any node) sits right after them, whereactionssits at the far end;flushdrops the padding and clips the corners, for a table that runs edge to edge;barmoves the title, count andactionsinto a tinted strip over the body;dashedis an empty-state shell with centred muted text, anddashed="filled"the same on a surface fill with more padding;badgepins aPillto the top-right corner over the content, on a panel or a link card, where it is part of the link's name;iconputs one of theIconset's names above the title atfeaturesize in the accent colour, decorative, on a panel or a link card but not abar,flushordashedone;attentiondraws the strong warn edge (border-amber-600) round a card that needs a look, with or withouttone="warn", and does nothing on adashedcard;footerputs a tinted strip under a hairline across the bottom, for the card's own actions or a total: it takes the padding off the root and gives it to the body, so the strip runs edge to edge and stays at the foot of a card stretched in aCardGrid; not on adashedcard or a card that is a link;insetis a full-bleed band (a progress bar, a chart, an image) under the title and over the children, running edge to edge of a padded card and not on adashedcard; a bordered sub-card inside a card is a nestedCard tone="neutral";asissection(default),articleordiv;id,data-*andaria-*go on the root (on a link card too: the link inside takes only its ownaria-label,aria-labelledbyandaria-current); a titled panel is named by its title througharia-labelledby, unless you pass your ownaria-labeloraria-labelledby; a Card with anidis an anchor target, scrolled clear of the sticky header and outlined in violet while it is the:target),EpicHeader,Pill(a small tinted label;tone,dot,roundandtitle;labelmakes it a two-tonelabel: valuepill, the label keeping the tint and the children, the value, on the surface;href/rendermake it a link andonClicka button;onRemovewithremoveLabeladds an x button, a separate button beside a link or button pill and never inside it),groupByEpic,Markdown,Badge(a small red count for sitting on an icon;countabove 99 reads99+,cornerpins it to the top-right of itsrelativeparent; anIconButtontakesbadgeand does this itself). - Data:
StatTile(one headline figure: an uppercaselabelover a large tabularvalue, already formatted, an optionalhintunder it;toneisneutral(default),positiveornegative, andaccentadds a bar across the top in the same tone),ProgressBar(a thin bar with a filled share ofmax(default 100);labelnames it for a screen reader, which also hears the percentage;toneisaccent(default),positive,warnornegative),SplitBar(a bar split betweenaandbby their shares;namessays whose is whose to a screen reader;toneA(defaultnegative) andtoneB(defaultpositive)),StackedBar(any number ofsegments, each{ label, value, tone }, sized by its share of the sum;legenddraws aLegendunder it) andLegend(a swatch, alabeland an optionaldetailper item, the key to a chart's or a row stripe's colours). The three bars takesize:sm6px,md8px (default),lg12px. - Status pages:
NotFound(404),ErrorPage(500 or any failure;detailis error text shown in aCodeBlock, never a stack trace, andonRetryadds "Try again"),Offline(onRetrytoo),MaintenanceandForbidden(403). Each is a whole page with default copy:titleandchildrenreplace the heading and the body,actionis the primary action, andhomeHref, orrenderHomefor the app's own router<Link>, adds a "Back to home" button. With neither there is no home link. - Layout (see Layout):
AppShell,AppShellBrand,appShellRootClassFor,Container,PageHeader,Stack,Section,Cluster,ClusterGrow,SideColumn, and the page layoutsListLayout,DetailLayout,FormLayout,ProseLayout,CardGrid,SidebarLayout,FullBleedLayout,CenteredLayout. - Typography roles (each pins size + weight, takes a
colorand anas):Display,Lead,PageHeading,SectionHeading,SubsectionHeading,Overline,Text,Caption. Opt-in props, none changing today's look:SectionHeading rule(a hairline under it, with its spacing),SubsectionHeading size(sm,mddefault,lg) andtruncate(one line with an ellipsis),Text strong(font-medium in the primary ink, for emphasis),Text preserveNewlines(keeps a note's line breaks). The three inline roles (Text,Caption,Overline) also taketone(muted,positive,warn,dangerorinfo: the same text colours as aTableCelltone, winning overcolor; pair the colour with a word, as in "Saved."),numeric(tabular figures),mono(the monospace face) andtruncate(one line, cut with an ellipsis, the full text in atitle, so its content is text). - Controls:
Button(variantisprimary(default),outline,ghost,linkordashed, the link having no box: text with an underline on hover, for an in-line action, and the dashed one a dashed outline in muted text that fills its container, likeCard dashed, for an "add another" row, with a leading icon as a child, ignoringtone;size="sm"is compact,text-xs, for table rows and toolbars,size="lg"istext-basewith more padding;tone="danger"is red, for delete and clear, andtone="positive"green, for accept and approve;disabledReasondisables it, greys it flat and makes the reason itstitle;busydisables it witharia-busyand keeps its colour, showingbusyLabelin place of the children;pressedmakes it a toggle witharia-pressed, onoutlineandghostneutral when off and filled in the tone's primary colour when on, so anoutlinesize="sm"Button withpressedis a filter chip;countputs a small number chip after the label, part of the button's name ("Splits, 3"), and a button that opens something also takesaria-expanded; withhref, orrenderto draw the app's own router<Link>, it is a link with the same look,externalopening a new tab),ButtonGroup(a wrapping row of buttons;endis one item pushed to the far side,labelmakes it a namedrole="group"),IconButton(a square button holding one icon;labelis required and becomes itsaria-labelandtitle;variantisoutline(default) orghost,toneisdefaultordanger,sizematches aButton's height;badgeputs a red count on its corner, not shown at 0; withhreforrenderit is a link,externalopening a new tab),Icon(one bare<svg>sized to fill a box, ornamefor one of the set's own icons (diff,baseline,review,ci,self-host,privacy,capture,branch,theme,speed: one 24 grid, a 1.5 stroke, round caps and joins, each the idea and not a product):sizesm14px,md16px (default),lg20px orfeature32px;toneis a text tone orinherit(default, the text colour around it);aria-hiddenunlesslabelnames it asrole="img"),ColourSchemeToggle(a light/dark switch on<html>;id,data-*and other button props go on the button,store={false}skipslocalStorage; see Light and dark),Input(a text input;iconadds a leading icon inside the field;prefixis short text inside the border before the value, a currency symbol, on a plain field only;type="search"drops the browser's search look and shows a "Clear search" button while the field has a value),Textarea(a multi-line text input,rowsdefaults to 3),Select(a native<select>in the control look; takes<option>children and keeps the browser's arrow).Input,SelectandTextareatakesize(mddefault,sm:text-xs, less padding, for dense forms and table rows, orlg:text-base, more padding) andwidth(fulldefault, the fixedxs5rem,sm7rem,md9rem,lg12rem,fill: grows to the room left in a flex row and can shrink to nothing, so the buttons beside it stay on the line, orauto: no width class, as wide as the browser makes it); a fixed width replacesw-full, so there is no class to collide with.Inputalso takesalign(startdefault, orendto right-align a numeric value).sizereplaces the nativesizeattribute, so a multi-row<select size={4}>is not reachable throughSelect.Checkbox(a native checkbox with its label; other props go on the input;size="sm"istext-xswith a smaller box andsize="lg"text-basewith a larger one,align="start"puts the box on the first line of a label that wraps, where the defaultcentercentres it on all of them, and with nolabelit is a bare box for a table cell, named byaria-label;descriptionis a muted line under the label, the box'saria-describedby).Switch(an on/off setting that acts at once, usually by saving to a server: a native<button role="switch" aria-checked>, controlled only, withcheckedandonChange(next); give it alabel(the whole label is the click target) or, with none, anaria-label: one is required;descriptionis a muted line under the label and itsaria-describedby;sizeissm,md(default) orlg;busyis the save in flight:aria-busy, a click or key does nothing, it keeps its colour and its focus;disabledis the native one, washed out; a value sent with a form stays aCheckbox).RadioGroupandRadio(one choice from a few options: the group has alabel,value/onChangeordefaultValue,name,isDisabled,isRequired,size(sm,mddefault,lg, asCheckbox) and anerrorshown likeField's withrole="alert"; eachRadiohas avalue, alabel, an optionaldescriptionandisDisabled; one tab stop, the arrow keys move and choose).Disclosure(a summary button with a chevron andaria-expandedover a body that opens and closes;summary,children,defaultOpen; it keeps its own state and replaces<details>).FileDrop(a dashed drop zone, a<label>around a file input that covers it, so a click opens the dialog and a dropped file lands on the input;childrenis the prompt,accept,multiple,name,disabledandonChangeon the input).CopyButton(a small text button that writestextto the clipboard and swapslabel("copy") forcopiedLabel("copied"); every other button prop,disabledincluded, goes on the<button>).CopyLine(one line of mono text in a box with aCopyButtonbeside it;disableddisables the button; other props go on the root<div>).Field(a label above a control,htmlFornames the control's id;hideLabelkeeps the label for assistive tech and draws no text, for a control whose purpose is plain from what surrounds it, and makeslayout="row"a column; ahintsits under it, and anerrorreplaces the hint with arole="alert"line;span="full"runs it across both columns of aFormGrid).FormGrid(two columns ofFields frommd, one below it),FormRow(an inline-edit row:FormRowFields side by side, anactionafter them, and onehintorerrorunder the whole row; see Layout) andFormRowField(a compact label over one control,span1 to 4).Callout(a tinted notice box;toneisneutral,info(default),positive,warnordanger; an optionaltitlesits in bold above the body;actionsputs a button or link at the end of the row;bannermakes it a flat strip, bottom border only and no radius, for the top of aCard flush).ListRow(a full-width row that is a<button>, or a link withhreforrender, with a hover fill, a left-aligned label andtrailingcontent;divideddraws a hairline above the row, to separate rows in aCard flush;expandedmakes it the header of a collapsible section:aria-expandedand a chevron, the caller owning the state and the section;leadingis content before the label (anAvatar), andleadingWidth="thumbnail"gives it a fixed width so rows with and without a picture line up;sizesets the padding (smdense,mddefault,lg);focuseddraws the focus ring on the row the keyboard cursor is on, for a list moved with the arrow keys while focus stays on the list;selectedmarks the row in view with a violet fill andaria-current="true"; a link row (hreforrender) can hold its own link, button or control intrailing, which sits above the row's link and takes its own click;staticmakes it a plain<div>, for a row that holds its own buttons where the row would otherwise be a<button>, and addsactions, buttons aftertrailing, andrevealOnHover, which shows them only on hover or focus inside the row and always on a touch screen; a compact bordered list isCard flusharoundstaticrows atsize="sm",dividedafter the first (no separate part);activemarks the row the pointer or the keyboard is on, with the hover fill and therevealOnHoveractions shown, for a list that follows both through the row'sonPointerEnter,onPointerLeaveandonFocus, which every row passes through).Tabs(underline tabs for switching a view in place: a row over a bottom rule, the current tab underlined in the accent;itemsare{ value, label }buttons, withvaluethe current one'svalueandonChange(value)the caller's state, one tab stop and the arrow keys, Home and End moving between enabled tabs, or{ label, href | render, active?, external? }links in a<nav>witharia-current="page"on the current one (active, or the item whosevalueisvalue);labelnames the group; a button tab takesdisabledandcontrols, the id of itsrole="tabpanel", which is the caller's;variant="pill"fills the current tab, with no rule, forPageHeader'stabsslot, anAppShellnavor a rail; only a pill row takessizeandorientation, andorientation="vertical"stacks it in one column, the content of aSidebarLayoutrail;disabledon the row disables every tab, e.g. until hydration).SegmentedControl(a joined pill group, one option filled; the sameitems,value,onChange,disabledandlabelasTabs, as aradiogroupwitharia-checkedon eachradio). On both an item takesdata-*attributes (typeddata-${string}) onto its button or link; onTabsandSegmentedControlan item takescount(a small muted number after the label, part of its name: "Shots 12") andtitle; adisabledbutton option stays in the page asaria-disabled, so itstitleis still the hover tooltip and is announced as the reason, it is out of the tab order and the arrow keys, Home and End skip it.Dialog(a modal on the browser's<dialog>, opened on mount, so render it only while open:titleis its name,descriptiona line under it,footerthe right-aligned actions; Esc, the backdrop or a<form method="dialog">callonClose;sizeissm(320px, a list),md(440px, default, a form),lg(640px, a wider form or a table) orfull(the whole screen, for media or a player: the body fills the height between the title and the footer and scrolls if taller; there is no backdrop to click, so put a close button on a<form method="dialog">in thefooter)).FieldDialog(aDialogwith one text field and Cancel and submit buttons, every string passed in:title,description,label,inputLabel,placeholder,hint,emptyReason,submitandbusyLabel;onSubmitgets the trimmed value and a resolved promise closes it, a rejection shows its message).Drawer(a modal slide-over on the right edge, on a native<dialog>opened on mount likeDialog, so render it only while open:titleis its name, a close button sits beside it,childrenscroll between the header and an optional pinnedfooter; Esc, the scrim and the close button callonClose;sizeissm,md(default) orlg;closeLabelnames the close button).Breadcrumbs(a<nav aria-label="Breadcrumb">over an ordered list;crumbsare nodes, or{ label, href | render, external? }for one that links; the last is the current page and is never a link and getsaria-current="page", and each crumb truncates).Table(a list of records in a rounded, bordered card around a full-width<table>that scrolls sideways on a narrow screen;captionnames it for assistive tech and is not drawn; compose it fromTableHead,TableBody,TableFoot,TableRow,TableHeaderCell(alignstart,centerorend;srLabelis a visually hidden column name, in place of children, for a column of row actions: "Actions") andTableCell(align,indentof 1 to 3 to set a start-aligned cell in, for a line under a parent row,tonedefault,strongormuted, or the Callout tonesneutral,info,positive,warnordangerfor an amount that means something, in the tone's text colour and with no fill, so pair the colour with a sign or a word;numericfor tabular figures,monofor a small mono face,truncateto cap a long value's width, cut it with an ellipsis and put the full text in atitle, so its content is text (strings and numbers);secondaryfor a second, muted line under the content;colSpanpasses through, so aTableCellwithcolSpanin a row of its own is a full-width panel, an empty state or a note, centred withalign="center");TableEditCell(aTableCellwhose value opens an editor in place:childrenis the value, drawn as a button; a click, Enter or Space swaps it forrenderEditor(close), a plain control in the cell and not a popover of its own, and moves the focus into it;closeand Esc put the focus back on the value, and a press outside or Tab out of it closes it too, so the control saves on its own change or submit;labelnames the button and the editor; it takes theTableCellprops excepttruncate);InlineEdit(the same edit trigger without the cell, for aTableCellthat holds other content too:label,renderEditor(close)andchildren, with the same keyboard and focus behaviour);TableFoot(the<tfoot>for totals: its cells are strong by default and each row has a firmer line above it, in a card and in aplaintable alike);TableGroupRow(a full-width group header in the body:labelspanscolSpancolumns, the table's column count, which is required; optionaltrailingcontent sits at the far end, and the group's rows follow it);toneon aTableRowin the body marks the row with a stripe on its leading edge over a soft fill:neutral,info,positive,warnordanger, asCallout, ormuted, which only fades the row (a transfer, a row that is not the point); a bold subtotal is not a tone:emphasison aTableRowin the body gives it aTableFootrow's look among the other rows;stripe(a text tone) draws the leading stripe alone, with no fill; a row takes one oftone,stripeandemphasis;variant="plain"onTabledrops the card border, fill and shadow, the head strip and the row hover and keeps the row dividers, for a table inside a card of its own; every table element passesid,data-*andaria-*through, and none takes aclassNameorstyle).Timeline(a named<ol>of events;labelis itsaria-label,emptyis drawn instead when there are no items) withTimelineItem(atitle, an already-formattedtime, thechildrenas a note, andtone, a Callout tone for the marker dot,neutralby default).KeyValueList(a<dl>of label/value rows; eachKeyValuetakes alabeland its value as children, the value right-aligned in tabular figures;totalmakes it the sum row, a firm rule above and strong text, andtone(muted,positive,warn,danger,info) colours the value on any row, sototal tone="warn"is a total that does not reconcile;align="start"puts the values left-aligned in one column beside the labels instead, using CSS subgrid: in Chrome before 117 a row stacks its label over its value).Avatar(a round badge of the initials ofname, titled with the name andaria-hidden; withsrc(the app's own local image path, never a remote one) it is a round, cropped<img alt={name}>instead, and if that image fails to load it shows the initials;sizeisxs(16px, for an inline mention),sm,md(default),lg,xl(56px),2xl(64px) or3xl(96px);onClickmakes it one button named byname;ringadds a page-colour border and a negative margin for an overlapping stack; the colour is one of seven stock background classes picked by a hash of the name, remapped by each theme, andavatarColourClass(name)returns it to colour something else the same way;initialsOf(name)is the two letters).TextLink(an inline text link in the accent colour;hrefis required unlessrenderdraws the app's own router<Link>,externaladdstarget="_blank"andrel="noreferrer",stretchcovers the nearestrelativeancestor so a whole row or card is the click target,size(sm,mdorlg) pins the type size where it would otherwise follow the text around it,monosets the monospace face,tone(warn,danger,positive,infoormuted) gives it a meaning's colour in place of the accent, every other anchor prop passes through).MetaList(a wrapping row of facts or links in muted small text, joined by anaria-hiddenseparator:·(default),•ornone; branch · SHA · date, or a footer's Privacy · Terms; an item is text, aCodeor aTextLink;nowrapkeeps each item on one line, for links;monosets the row in the monospace face, for ids and paths).PageFade(the page's content area, named for the View Transitions API so a route change cross-fades it while the frame around it stays put; wrap what changes between pages, once per page, or give each aname(a CSS identifier) when there are two; it fades when the navigation runs in a view transition, throughstartPageTransition(update)(flushes a synchronous state change insidedocument.startViewTransition) or a router's own option such as TanStack RouterviewTransition, and otherwise, in a browser without View Transitions, or underprefers-reduced-motion: reduce, it swaps with no fade; it renders a small<style>, with no colour in it, and the div is a stacking context, so portal an overlay that must rise above the frame).EmptyState(what a page or list shows when it has nothing yet: a centredtitle, thechildrenas the explanation under it, andactionsin a centred row; a named<section>;levelis the title's heading level, 2 (default) to 6, and does not change its look; for one muted line inside a card,Text as="p" color="muted").Steps(a numbered<ol>ofSteps, each atitleand thechildrenas a detail line, the numbers counted by CSS in a violet disc; for a "get started" list, not for progress).Divider(a hairline<hr>in the theme's line colour; spacing comes from the layout around it).BulletList(a<ul>with discs, an indent and a small gap; children are<li>s).Code(inline code: mono, on a subtle background).CodeBlock(a block of code, no client JS: the text ischildren(a string) orlines(one entry per line),language(bash,css,tsx,tsorjson) colours it andtext, the default, leaves it in one colour;labelis a caption naming the language or file; long lines wrap by default, so nothing is cut off and a command copies as one line, andoverflow="scroll"keeps lines whole and scrolls them under a thin scrollbar in the theme's muted ink;copyadds a copy button beside the code, which copies it exactly as given and needs the page hydrated; other props go on the root<figure>).CodeExample(one framed example: the renderedchildrenover thecodethat makes them, coloured as TSX, in one bordered box, with an optionalcaptionas its last band; a wide preview or long code scrolls inside the frame under the same thin scrollbar;copyadds a button that copies the code; no client JS withoutcopy).Video(a full-width, rounded<video>with controls that loads metadata only;srcis required,posterand a WebVTTcaptionstrack are optional, children are the fallback).Kbd(a keyboard key in mono; its border takes the surrounding text colour, so it reads on a filled button too).FilterGroup(a<fieldset>with a small uppercaselegendover a wrapped row of its children, e.g.outlinesmButtons withpressed).DateField(see Dates). - Dates:
toISODate,fromISODate, and theCalendarDatetype.
Dates
import { DateField, fromISODate, toISODate, type CalendarDate } from "@hackcove/ui-react";
<DateField
calendar
label="Date of birth"
description="DD/MM/YYYY"
defaultValue={fromISODate("1990-05-17")}
maxValue={fromISODate("2026-10-23")}
onChange={(date: CalendarDate | null) => save(toISODate(date))}
name="dob"
isRequired
/>;DateFieldis a segmented DD/MM/YYYY field (always en-GB, whatever the browser's locale).calendaradds a calendar button. The popover has ‹ › month steps, month and year dropdowns, Monday-first weeks and a Clear action.defaultOpenopens it on mount.- Without
calendarit is typed only, for a date the person knows by heart. - It takes
label,description,errorMessage,value/defaultValue/onChange,minValue,maxValue,isDisabled,isRequiredandname(the form postsYYYY-MM-DD). Values areCalendarDates (from@internationalized/date, re-exported as a type). toISODate(date)gives"1990-05-17"(""fornull);fromISODate(text)gives aCalendarDate, ornullfor a malformed or impossible date such as"1990-02-31".onChangereports what the field shows: the date once it is complete, exists and is withinminValue/maxValue. When a valid date is edited into one that is not, it reportsnullonce andnameposts"", so a controlled parent holdsnulluntil the date is fixed (holding it does not clear what was typed). Fixing it reports the date again. Avaluethe parent sets is shown, not reported back.- With
isRequired, a partly filled date blocks the form and shows "Enter the whole date." once the field loses focus. - A date before
minValueor aftermaxValuestays as typed (it is never clamped) and shows the error once the field loses focus. - An impossible date such as 31/02/1990 shows "February 1990 has 28 days." and stays exactly as
typed, focus leaving included, until it is fixed.
errorMessagereplaces either message. erroris an error from outside the field, a server's say: it shows in place of the description, in red withrole="alert"(asField'serrordoes), and marks the field invalid. It wins over the built-in messages; clear it when the date changes.isInvalidmarks the field invalid with no message of its own; pair it witherrorMessage.
Router links
@hackcove/ui-react has no router dependency. Tabs and SegmentedControl (an item with href or render), Breadcrumbs (a crumb), TextLink,
ListRow, Pill, IconButton and Button take a render function that draws the app's own <Link>, handed the component's
classes (and aria-current on the active tab) to spread on it. The call site carries no className:
<Tabs
variant="pill"
label="Main"
items={[
{ label: "Quotes", active: true, render: (props) => <Link to="/quotes" {...props} /> },
{ label: "Traces", href: "https://traces.example.com", external: true },
]}
/>
<Button variant="ghost" render={(props) => <Link to="/quotes" {...props} />}>Back</Button>With render, href is not used. The props render is handed are the exported type LinkRenderProps:
className and children, plus aria-current, target, rel and title when there is one. Each
optional field is ?: T and an absent one is left out, never set to undefined, so the spread
compiles on a router <Link> that declares its props the same way under exactOptionalPropertyTypes
(TanStack Router's does). Any other anchor prop you gave the component (id, download, a handler)
is handed over too. The class strings are not exported: render is the one way to draw a router
link.
Layout
One content edge for every page, so nothing moves sideways between pages or viewport sizes.
import { AppShell, PageHeader, Section, Stack, Tabs, appShellRootClassFor } from "@hackcove/ui-react";
// Your root layout. `appShellRootClassFor` adds `overflow-y-scroll`: it reserves the scrollbar, so
// a short page and a long one centre their content at the same x. AppShell cannot set it itself.
<html className={appShellRootClassFor("light")}>
<body>
<AppShell brand={<Logo />} nav={<Tabs variant="pill" label="Main" items={nav} />} footer="…">
<PageHeader title="Builds" subtitle="Every build" actions={<Button>New</Button>} />
<Stack>
<Section title="Recent">…</Section>
</Stack>
</AppShell>
</body>
</html>;AppShellis the page frame: a sticky header that is oneh-14row on a phone and, frommd, anh-14row plus anh-12subnavrow. On a phonenavandsubnavfold behind one Menu button (a native<details>, so it opens with no JavaScript, on the keyboard and for a screen reader; Escape closes it and a followed link closes it). The panel hangs below the header and never changes its height, nothing wraps and nothing scrolls sideways.menuLabelrenames the button (defaultMenu). Frommdthe nav and the subnav are rows again (a row wider than the screen scrolls sideways, never wraps, and the edge with more to reach fades out).mainhas fixedpy-8, and an optionalfooter. The header row is filled by slots, left to right:brand(logo and name, at the brand type with the logo gap),nav(pillTabsof links),header(a free node, the escape hatch) andcontrols(end-aligned,gap-2, centred): no wrapper and no styling. Nothing incontrolsshrinks, and a full-width control there (aSelectat its defaultwidth="full") sizes to its content, so aSelectnext to aColourSchemeToggleleaves the toggle its square.hideChromeInPrintis opt-in: the header and footer are hidden in print and main drops its padding (the page margin is then your@page); without it a print is unchanged.bleeddrops main'sContainerfor a full-bleed page; the header keeps its own, so put aContainerback around anything that should line up.AppShellBrandis thebrandslot as one link:logoandlabelinside the same<a>, so the whole brand goes home (the label names the link; the logo is hidden from assistive tech).logoSizeissm(20px),md(24px, the default, anAvatar size="sm") orlg(32px); an<img>or<svg>logo fills it. Withrenderit draws your router's<Link>, asTabs,BreadcrumbsandTextLinkdo:<AppShell brand={<AppShellBrand logo={<Avatar name="Quotes" size="sm" />} label="Quotes" render={(props) => <Link to="/" {...props} />} />} …>A plain node in
brandstill works. Stories:Layout/AppShell › LinkedBrand,RouterBrand.appShellRootClassFor(scheme, schemes?)is what goes on<html>when the server knows the scheme: that scheme's class andoverflow-y-scroll, joined, so there is no string concatenation:<html className={appShellRootClassFor(scheme, schemes)}>. With no scheme, pass"light".schemeClassstays exported.Containeris the only place the content width (max-w-6xl) and side padding (px-4 sm:px-6) are defined. Pages never set their own width or outer padding: nomax-w-*, nomx-auto, nopx-*on a page. AppShell already gives them the edge.PageHeaderis the top of every page: optionalbreadcrumbs, thetitle(aPageHeading),subtitle,actions(right of the title fromsm, under it on a phone) andtabsunder it. Its fixed bottom margin puts every page's first block at the same y.Stackspaces blocks in a column:gap="xs"(gap-2) between lines of one text block (a heading, the line under it),sm(gap-4) inside a group,md(gap-6, the default) between blocks,lg(gap-8) between groups,xl(gap-12,sm:gap-16) between a page's top-level blocks. Use it instead ofspace-y-*.widthcaps the column:narrow(max-w-3xl, a reading measure),wide(max-w-5xl),full(w-full) ordefault(nothing). A capped Stack starts at the Container's left edge and is never re-centred.rowFrom(sm,mdorlg) lays the blocks out in a row from that breakpoint and keeps the column below it, a main block beside a side panel that stacks on a phone; a row that is a row at every width is aCluster.asisdiv(default),section,form,ulorol, and nothing else. AformStack takes the form props (onSubmit,action,method,noValidate,aria-*,data-*). AulorolStack has no markers and its children are<li>s (the Stack does not wrap them, as withBulletListandTimeline;Text as="li"works).Sectionis a<section>Stack (mdby default, likeStack) with an optionalSectionHeadingtitle,actionsbeside it and the samewidth.Clusterspaces items in a row, the counterpart ofStack:gapisxs(gap-2, the default),sm(gap-4) ormd(gap-6),alignthe cross axis (center(default),baseline,startorend),justifythe main axis (start(default),betweenorend), andwraplets the row flow onto more lines on a narrow screen (off by default). Wrap the one item that should take the room left inClusterGrow, an input beside its buttons say: it can shrink to nothing, so the items beside it stay on the line;minWidth(sm12rem,md17rem) gives it a floor instead, so in awrapCluster it drops under the others rather than squashing. An emptyClusterGrowis a spacer that pushes what follows it to the end.ButtonGroupstays the row for buttons;Clusteris any other row. It is a separate part, notStack direction="row", because a row has nolg,xlorwidthand a column has noalignorjustify.FormGridis the two-column form:Fields in two columns frommd, one below. AFieldwithspan="full"(a textarea, a long hint) runs across both.FormRowis an inline-edit row of **FormRowField**s (a compacttext-xslabel over a control;span1, 2, 3 or 4 is its share of the row, so 2, 1 and 1 give a half and two quarters) with an optionalactionafter them (a save button, kept at its own width). Every control sits on the bottom edge, so the action lines up with them, and undermdthe fields and the action stack. AFormRowis always the full width of aFormGrid. Itshintorerror(which replaces the hint,role="alert") sits under the whole row; aFormRowFieldhas no note of its own, so the controls beside each other stay level. Closed spans, nocol-spanor width to pass.
Page layouts
Small compositions of the above: they take slots, own no data, and set no page width or outer
padding. Each takes the PageHeader props (title, subtitle, actions, breadcrumbs, tabs)
except the last four.
ListLayout: use this when a page is a filterable list.filterssits under the header;childrenis the list, replaced byemptywhenisEmpty.DetailLayout: use this when a page is one thing with facts beside it.childrentake two thirds fromlgandasideone third; belowlgthe aside stacks under the main column.stickyAside(off by default, since an aside of ordinary cards may be taller than the screen) keeps the aside in view fromlgas a long page scrolls: it sticks under the shell's header and scrolls itself when it is taller than the room left.asideLabelnames the aside's landmark (it never folds it), for a page with another complementary landmark.FormLayout: use this for a create or edit page. Amax-w-3xl<form>that starts at the Container's left edge (it is not re-centred, so the title does not jump), with anactionsrow at the end. Other props (onSubmit,action) go on the<form>.ProseLayout: use this for a text page (privacy, terms, a CV, an about page). Onenarrow(max-w-3xl) column that starts at the Container's left edge (not re-centred, so the title does not jump): the header, thenchildrenin aStack(gapprop,mdby default). UnlikeFormLayoutthe header sits inside the column, so itsactionsstay beside the text.CardGrid: use this for stat tiles or a gallery of cards.columnsis2,3(default) or4: one column on a phone, two fromsm,columnsfromlg.columns="auto"instead fits as many per row as the width allows, each at leastminWidthwide:md(17rem, default) orsm(12rem);minWidthis only forauto.columns="2:1"or"1:2"makes two columns in that ratio frommd(one column below), for a main card beside a narrower one.align="start"keeps each card its own height; the default stretches every card to the tallest in its row.as="ul"makes it a list, each child wrapped in an<li>.SidebarLayout: use this when arailof navigation sits beside the content. Render it in a plain<AppShell>, nobleed: it sits in the Container like every other layout, so the rail starts at the header logo's x and the canvas ends at the header's right edge. The rail has no fill and no border (pillTabswithorientation="vertical"is its content); frommdit ismd:w-56beside the canvas, and belowmdit stacks above it. Frommdthe rail also stays in view under the shell's header as a long page scrolls (always, with or withoutrailLabel); a rail taller than the screen scrolls itself, so its last link is reachable. A long rail setsrailLabel: belowmdthe rail then folds, closed, behind a disclosure of that name, so a phone shows the page first;railLabelalso names the rail's<aside>landmark.SideColumn: use this when part of a page is a main area beside a side column of a known width, such as a thread beside an assistant.childrenare the main area andasidethe column; fromlgthe aside isasideWidthwide on the right (sm18rem,md24rem, the default) and the main area takes the room left, and belowlgthe aside stacks under it.asideLabelfolds the aside, closed, behind a native disclosure of that name belowlg(the aside stays mounted, so state inside it is kept) and names the<aside>landmark; fromlgit is the same column, always shown. It sets no page width, padding or header of its own, so it sits inside a layout or a card; a share of a page under aPageHeaderisDetailLayout's aside. AStackwithrowFromand aClusterGrowsizes the side by its content and is not a fixed column. A grid of fields, one row per item, is aplainTablein aCard.FullBleedLayout: use this for a viewer that needs the whole width. Render it in<AppShell bleed>;childrenfill the width, and an optionalpaneltakeslg:w-72on the right and stacks below underlg. It adds no padding, so a viewer fills the width; setpaddedfor a body that is not a viewer, which gives the canvas the other layouts' page padding. Abar(a viewer's title and actions) runs full width above both on the surface with a bottom line, and sticks under the shell's header as the page scrolls;paddeddoes not pad it.panelLabelnames the panel's<aside>landmark.FullBleedLayoutdeliberately breaks the shared left edge fromlgup. An edge-to-edge viewer needs the viewport's edges, so it cannot sit inside the Container the header uses. At 1280 px the header logo and every other layout start at x=88,FullBleedLayout'schildrenat x=0 (unlesspadded). Belowlgit stacks and agrees with the other layouts. To avoid the shift between pages, give a whole section of an app the same layout rather than mixing it withListLayoutpages that a user moves between; the header, which stays inside the Container, is the constant. The title's y also differs fromListLayoutonly when one page passesbreadcrumbsand the other does not:PageHeaderputs them above the title in every layout.CenteredLayout: use this for sign-in, landing, not-found and error pages. Amax-w-mdcard centred at a fixed offset from the top; the one layout that does not share the left edge.
<AppShell header={header}>
<ListLayout title="Quotes" actions={<Button>New</Button>} filters={<FilterGroup …/>}
isEmpty={quotes.length === 0} empty={<Text as="p" color="muted">No quotes yet.</Text>}>
{quotes.map(…)}
</ListLayout>
</AppShell>Light and dark
A dark theme does its colour work under .dark; this only decides whether .dark is on <html>.
Two states, light and dark. The stored choice wins; with nothing stored the system's
prefers-color-scheme decides. Which dark theme is loaded stays your CSS import.
Inline
colourSchemeScriptin<head>, before the stylesheet, so the class is right at first paint and neither scheme flashes the other. It is a few hundred bytes of plain JavaScript:import { colourSchemeScript } from "@hackcove/ui-react"; <head> <script dangerouslySetInnerHTML={{ __html: colourSchemeScript }} /> </head>;In Astro, use
<script is:inline set:html={colourSchemeScript} />.Drop the hard-coded
class="dark"from<html>. The script sets and removes it (andcolor-scheme, so native controls and scrollbars follow). For a server-rendered<html>, addsuppressHydrationWarning(React) because the script changes itsclassbefore React hydrates.Place the toggle wherever the header wants it:
import { ColourSchemeToggle } from "@hackcove/ui-react"; <ColourSchemeToggle onChange={(scheme) => track(scheme)} />;It is an icon button showing the scheme you would switch to (a sun on a dark page, a moon on a light one), labelled "Switch to light theme" / "Switch to dark theme". A click applies the scheme to
<html>and stores it underlocalStorage["hackcove:colour-scheme"]; a storage error (private mode) is swallowed and the page still switches. It reads the current state from thedarkclass when it mounts, so it agrees with the script and renders the same on the server and the client. It draws both icons and a small<style>shows one from the class on<html>, so a dark visitor with no cookie and noinitialsees the sun from the first frame, not the server's light-page moon; the button's name is drawn the same way (both names are in the markup, visually hidden, and the rule hides the wrong one), so a screen reader hears the right one before hydration too, with no script. Only the tooltip waits for hydration.A light theme with a class of its own, or a scheme the server knows. An app that picks the scheme from a cookie and whose light theme has a scope class passes both:
import { ColourSchemeToggle, schemeClass } from "@hackcove/ui-react"; const schemes = { light: "my-light", dark: "dark" }; // default: { light: "", dark: "dark" } <html className={schemeClass(scheme, schemes)}> <ColourSchemeToggle schemes={schemes} initial={scheme} onChange={(next) => saveCookie(next)} />Leave
colourSchemeScriptout here: it knows only thedarkclass and the stored choice, so it would fight the server.schemesis the class each scheme puts on<html>; a click swaps them.initialis the scheme the server rendered: the server markup, the hydrating client and the<html>class (fromschemeClass) then agree, with no flash. Persist the choice fromonChange; the toggle still writeslocalStorage, which an app reading a cookie simply ignores. With neither prop nothing changes:darkon and off, light on the server.Persisting to a cookie instead of
localStorage. Passstore={false}(defaulttrue) and write the choice fromonChange; the toggle then touches no storage at all:<ColourSchemeToggle store={false} initial={scheme} // read from the cookie on the server onChange={(next) => { document.cookie = `scheme=${next}; path=/; max-age=31536000; samesite=lax`; // or POST it to the server, which sets the cookie }} />The toggle takes
id,data-*and the rest of a button's props, which go on its button. It has noaria-pressed: its label already says what a click does ("Switch to dark theme"), and a pressed state on a button whose label changes would say it twice.
Without React, applyColourScheme(scheme, root?, schemes?), readColourScheme(fallback?),
storeColourScheme(scheme) and schemeClass(scheme, schemes?) are exported too; ColourScheme is
"light" | "dark" and ColourSchemeClasses is the schemes shape.
Palette
palette.json (in the repo, not in the package) lists the colour utilities the components may use,
by role.
| Role | For |
| ---------- | --------------------------------------------------------------------- |
| canvas | The page behind the cards: AppShell paints it. |
| surface | Cards and panels: the main background. |
| subtle | A step off the surface: table stripes, hover rows. |
| inset | A step deeper: wells, chips, pressed fills. |
| line | Borders and dividers. |
| ink | Text, from primary down to muted, plus input placeholders. |
| accent | Primary actions, links, focus rings and controls; text on the accent. |
| positive | Success: fill, text and border. |
| warn | Caution: fill, text and border. |
| danger | Errors and destructive actions: fill, text and border. |
| info | Neutral notes: fill, text and border. |
A theme remaps every utility in palette.json; a component uses no colour utility outside it.
Develop
From the workspace root: bunx nx run-many -t typecheck lint test build -p ui-react and
bunx nx pack-check ui-react (the tarball is dist/ plus package.json, README.md, LICENSE; it also typechecks the tarball in a
consumer app: scripts/consumer-types.ts).
The stories live in apps/storybook.
