npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@haruhimemoe/ui

v0.27.1

Published

React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces and player cards, a command palette, content sectio

Readme

@haruhimemoe/ui

React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), a SegmentedControl view switch and a CopyField copy row, list and page layout pieces (Surface, LinkCard, CardGrid, StatList, LinkRow, SectionHeading, EmptyState, Progress, PrevNext, CodeChip), tables, osu! beatmap display pieces and player cards, a command palette (mod+k, with the defaults every tool shares), content section tooling (nav, search, index and page), brand pages and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry "use client" in their own files, so you import everything from one place.

See every component in its states at haruhime.moe/ui. The page names the version it runs.

This README describes version 0.13.0. Anything marked "since 0.13.0" is not in 0.12.x, anything marked "since 0.12.0" is not in 0.11.x, anything marked "since 0.11.0" is not in 0.10.0, anything marked "since 0.10.0" is not in 0.9.0, anything marked "since 0.9.0" is not in 0.8.0, anything marked "since 0.8.0" is not in 0.7.0, anything marked "since 0.7.0" is not in 0.6.0, anything marked "since 0.6.0" is not in 0.5.0, anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 0.3.0, anything marked "since 0.3.0" is not in 0.2.0, and anything marked "since 0.2.0" is not in 0.1.0. CHANGELOG.md lists what changed in each version.

Requirements

  • Next.js 16 (app router)
  • React 19
  • Tailwind CSS 4.1 or later, below 5

These are peer dependencies. The package is ESM only, and its engines field asks for Node.js 22.12 or later.

Install

bun add @haruhimemoe/ui

If the app doesn't have the peers yet:

bun add next react react-dom
bun add -d tailwindcss @tailwindcss/postcss

Setup

1. Load Tailwind through PostCSS (skip this if the app already uses Tailwind 4). Without this file Next generates no utilities, and the components render unstyled with no build error.

// postcss.config.mjs
export default { plugins: { "@tailwindcss/postcss": {} } };

2. Import the theme after Tailwind in the app's global stylesheet:

/* src/app/globals.css */
@import "tailwindcss";
@import "@haruhimemoe/ui/theme.css";

The theme does three things:

  • Adds the palette as Tailwind colors, so bg-b4, text-c1, border-h1 and the rest work in your own markup too.
  • Adds a visible focus ring (h1, 2px) to everything on :focus-visible.
  • Points Tailwind at the package's files with @source, so the classes the components use get generated. Without it the components render unstyled.

| Token | Use | | --- | --- | | b1 to b6 | Backgrounds, lightest (b1) to darkest (b6) | | c1 to c4 | Text, brightest (c1) to most muted (c4) | | h1, h2 | Highlights: h1 is the bright accent, h2 the deeper one (primary buttons) |

3. Load Nunito with next/font as the --font-nunito variable on <html>. The theme's font-sans uses it, and falls back to the system font without it.

import { Nunito } from "next/font/google";

const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display: "swap" });

// <html lang="en" className={nunito.variable}>

4. Pick a hue (optional). Every color comes from one --hue (default 333, pink). Set it on :root after the imports to recolor the whole app:

:root {
  --hue: 200; /* blue */
  --h2-l: 42%; /* keeps white text on primary buttons at 4.5:1 */
}

At some hues the defaults drop below 4.5:1 contrast, so check yours. Two variables fix it (and --c2-l, --c3-l, --c4-l set the content steps' lightness, default 90%, 80%, 70%):

  • --h2-l sets the lightness of h2 (default 45%). White text on h2 (primary buttons, the skip link) is under 4.5:1 for hues from about 23 to 205. Use 42% at hue 200, 35% at hue 150, or 31% for any hue.
  • --h1-l sets the lightness of h1 (default 76% since 0.7.0, 70% before). At 76%, h1 text on b5 (links in cards and prose) is at or above 4.5:1 at every hue. On b4 ("Clear filters" in a filter panel) it dips under for hues from about 238 to 248. Use 77% there.

The theme is dark by default (color-scheme: dark).

Light scheme (since 0.20.0). Import theme-light.css after theme.css for a white page:

@import "tailwindcss";
@import "@haruhimemoe/ui/theme.css";
@import "@haruhimemoe/ui/theme-light.css";

Same components, same class names. It sets the lightness variables the palette reads (--b1-l to --b6-l, --c1-l to --c4-l): b5 is the page, b4 white cards, b6 code and wells, and c1 to c4 are tinted ink, c1 darkest. The highlights swap jobs: h1 (36%) is the dark accent for links, rings and filled chips, and h2 (85%) the pale fill under c1 text on primary buttons and selected tabs. Code tokens get dark values. The wordmark's white turns to c1 ink through --wordmark-ink. Every ink step stays at 4.5:1 on every surface at every hue. h1 does at the default hue; for hues from about 30 to 200 set --h1-l: 21%. Under prefers-contrast: more the lift is -8%, so ink and h1 get darker and h2 lighter.

5. Motion, contrast and touch (since 0.12.0). The theme follows three browser settings. There is no in-app toggle.

  • Reduced motion. When the visitor asks for reduced motion (prefers-reduced-motion: reduce), every animation and transition finishes in 0.01ms and smooth scrolling turns off, in the kit and in your own markup. Put data-motion="essential" on an element whose movement is the message (a progress indicator): it and its children keep their motion. Tokens: duration-short (150ms: hovers, presses, color changes, and the default for every transition-*), duration-medium (250ms: panels, dialogs), duration-long (400ms: large surfaces), with ease-standard (the default), ease-enter and ease-exit. For motion JavaScript drives (an autoplay video, a canvas), read useMotionAllowed().
  • More contrast. Under prefers-contrast: more, c2, c3, c4 and h1 get 8 points lighter and h2 4 points darker. Cards, chips, secondary and ghost buttons, neutral badges and the tabs track get a c4 edge; fields, code blocks and menus swap their b3 border for c4. The lift adds to your --h1-l and --h2-l. Set --contrast-lift: 0% on :root to opt out.
  • Touch. coarse: is a variant for a touchscreen as the main pointer (@media (pointer: coarse), the same query as Tailwind's pointer-coarse:). Buttons at md, chips, checkbox and radio rows, tabs, menu items, palette rows and fields grow to 44px there, and fields use 16px text so iOS Safari doesn't zoom in. Undo it for one control with a class like coarse:h-9. A touchscreen laptop driven by its trackpad keeps the dense layout.

Example

// src/app/layout.tsx
import { PageShell, SiteFooter, SiteHeader } from "@haruhimemoe/ui";
import { Nunito } from "next/font/google";
import Link from "next/link";
import type { ReactNode } from "react";
import "./globals.css";

const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display: "swap" });

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" className={nunito.variable}>
      <body className="bg-b5 font-sans text-c2 antialiased">
        <PageShell
          header={
            <SiteHeader
              brand={
                <Link href="/" className="font-extrabold text-c1 text-lg">
                  packs
                </Link>
              }
              links={[
                { label: "Public packs", href: "/packs" },
                { label: "Docs", href: "/docs" },
              ]}
            />
          }
          footer={
            <SiteFooter
              columns={[{ title: "Help", items: [{ label: "Docs", href: "/docs" }] }]}
              finePrint="Not affiliated with osu! or ppy Pty Ltd."
            />
          }
        >
          {children}
        </PageShell>
      </body>
    </html>
  );
}
// src/app/page.tsx
import { ButtonLink, Card, PageHeader } from "@haruhimemoe/ui";

export default function Home() {
  return (
    <>
      <PageHeader
        title="Beatmap packs"
        lead="Pick maps, name the pack, share the link."
        actions={<ButtonLink href="/new">New pack</ButtonLink>}
      />
      <Card title="Recent packs" className="mt-8">
        Nothing here yet.
      </Card>
    </>
  );
}

Server and client components

Import every component from @haruhimemoe/ui, in Server and Client Components alike.

  • Client components: CopyButton, Chip, ChipGroup, RangeSlider and FilterPanel, and since 0.4.0 AsyncButton, InlineConfirm, Disclosure, ChoiceChips, RadioGroup, TypeToConfirm and HeaderMenu, and since 0.5.0 Tabs, VisibilitySelect and ReportDisclosure, and since 0.8.0 CommandPalette and CommandPaletteButton, and since 0.14.0 Dialog and ConfirmDialog, and since 0.16.0 MapPreviewButton and MapCopyScope (and the stopMapPreview() function). Each file starts with "use client". They merge their classes with tailwind-merge in the browser, so a page that renders any of them loads tailwind-merge (about 9 KB gzipped), however its header renders.
  • SiteHeader and NavLinks are Server Components with a small client part (since 0.2.0; in 0.1.0 NavLinks is a client component). When a nav link can be the current page (a path such as /packs), a client list reads the path to set aria-current. With only external or text-only links, the nav renders on the server alone and nothing in it hydrates. Relative hrefs (#main) skip the client list too, but they render next/link, which hydrates.
  • ContentNav (since 0.11.0) reads the path to mark the current page, like NavLinks, but it always hydrates: its class strings are finished (no cx), so it ships no tailwind-merge to the browser. CopyMarkdownButton (since 0.11.0) is the same: finished classes, no tailwind-merge, even though ContentPage (a Server Component) renders it. ContentLayout and ContentPage are Server Components. BrandSwatch (since 0.11.0) is the same again: BrandPage is a Server Component and only its swatches hydrate, with no tailwind-merge.
  • CopyField and SegmentedControl (since 0.13.0) are client components you render from your own "use client" file (a form, a toolbar), not ones a server component renders for you: they merge classes with cx like the other client components above.
  • MapCard, MapSetCard, MapGroup and MapCover (since 0.16.0) are Server Components; with copyId they render the kit's own internal Copy ID button, a small client file with finished classes (no tailwind-merge), the same pattern as CopyMarkdownButton and BrandSwatch. MapPreviewButton and MapCopyScope are the map display's own client files, for a page that renders them directly.
  • Everything else is server-safe: no state, no effects, no browser APIs.

A Server Component can't pass a function to a Client Component. So callback props (onChange, onPressedChange, onClear) have to come from your own "use client" file, like the filters example below. Props that are plain data (CopyButton's text, Chip's pressed) work from a Server Component. Pagination takes a function (hrefFor), but it is a Server Component itself, so that is fine anywhere. Its button mode (onPageChange) is a callback, so render that from a "use client" file.

Props, classes and refs

  • Every component takes its element's native props and passes them through (id, aria-*, data-*, event handlers). Each section below names that element. The tables list only the extra props.
  • ref is a normal prop (React 19). It goes where the native props go: the outer element for most components, the control (<input>, <select>, <textarea>) for the form fields, and the <button> for CopyButton. On PageShell that is the wrapper <div>, not <main>.
  • className is added after the built-in classes and wins on conflict: a class that sets the same property as a built-in one replaces it (merged with tailwind-merge). <Select className="w-auto"> drops the built-in w-full. On DiscordIcon, GitHubIcon and HaruhimeWordmark, className replaces the default size instead.

Translating built-in text (since 0.26.0)

Every string a component writes itself can be replaced, and every default is English. ui has no i18n dependency: pass your translator's strings in. Text props (label, copiedLabel and so on) cover most components. Components that write many strings take one labels object, merged over an exported English default. Values that depend on a number or a name are functions, so plural rules stay with your translator.

| Component | Prop | Default constant | | --- | --- | --- | | CommandPalette | labels: the count, key hints, list name, group headings, provider hints, argument errors, copy statuses | PALETTE_LABELS | | siteCommands | labels: every title, subtitle and group heading. Ids never change, so recents survive | SITE_COMMAND_LABELS | | CodeBlock (@haruhimemoe/ui/mdx) | labels: copy, copied, copyFailed, code, copyName(title), codeName(name) | CODE_BLOCK_LABELS | | ContentPage | labels: updated, readingTime(n), and, copyMarkdown, copied, copyFailed. locale formats the dates | CONTENT_PAGE_LABELS | | BrandPage | labels: section headings, Do / Don't, the osu! line, swatch statuses | BRAND_PAGE_LABELS | | SectionHeading | anchorLabel(text) | "Link to section: " | | SortableMoveButtons | upLabel(label), downLabel(label) | "Move up/down" | | ContentNav | summaryLabel | "Contents" | | ContentSearch | countText(total, matches), with matches null for an empty query | "12 pages." / "3 matches." | | MapGroup | countText(count, target) | "(3)" / "(3 of 5)" |

With next-intl, keep each object under one key and pass t.raw("ui.palette") for plain strings. Build the function entries from t:

const t = useTranslations("ui.palette");
<CommandPalette commands={commands} labels={{ ...t.raw("text"), results: (count) => t("results", { count }) }} />

The MDX element map (mdxComponents) still writes English for callout labels, embed titles and table names. Pass title on Callout and Embed to replace them.

Components

Basics

Button

A pill button. Every native <button> prop. Variants: primary (default), secondary, ghost, and since 0.14.0 danger (white on rose-700, rose-800 on hover) for a confirm that deletes.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | variant | "primary" \| "secondary" \| "ghost" \| "danger" | "primary" | primary is the h2 pill that lights up to h1 on hover, secondary is b3, ghost is transparent, danger is rose-700 (rose-800 on hover). | | size | "md" \| "lg" | "md" | Height, padding and text size. | | type | "button" \| "submit" \| "reset" | "button" | Never submits a form unless you ask for "submit". |

Since 0.12.0 a button sits at its content width (w-fit), also in a flex column or a grid cell; pass className="w-full" for a full-width one. At md it is 44px tall on a coarse pointer.

ButtonLink

A link that looks like Button. Every next/link prop (href, prefetch, replace, scroll, target, rel...), plus variant and size as on Button.

  • A string href with a scheme (https:, mailto:) or starting with // renders a plain <a>, and next/link's own props are dropped. The href is read the way the browser reads it: leading spaces don't count and a backslash counts as a slash, so /\host and \\host are off-site too (since 0.4.0).
  • That plain <a> with target="_blank" and no rel gets rel="noreferrer". A rel you pass always wins. Internal links get only the rel you pass.

Since 0.12.0, download (any value but false) renders a plain <a download>: no prefetch of an API route or a large file, no client routing. An external href also renders a plain <a>, with rel="noreferrer" in a new tab.

<ButtonLink href="/api/me/export" download variant="secondary">Download my data</ButtonLink>
<ButtonLink href="https://osu.ppy.sh" target="_blank">osu!</ButtonLink>

buttonClasses

buttonClasses({ variant?, size?, className? }): string returns the Button classes, for elements the components don't cover. Types: ButtonVariant, ButtonSize, ButtonClassOptions.

<summary className={buttonClasses({ variant: "secondary" })}>More</summary>

Card

The osu!-web panel: rounded, b4 background, p-5. Every native <section> prop except title.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | title | ReactNode | none | Rendered as a heading at the top (<h2> by default). It also names the section (aria-labelledby), which makes the card a region landmark. | | headingLevel | 2 \| 3 \| 4 \| 5 \| 6 | 2 | The title's heading level (type HeadingLevel). Use 3 or lower for a card that sits under another heading, such as a card inside a titled card. Since 0.2.0; 5 and 6 since 0.4.0. |

PageHeader

The page's one <h1>, with room for a lead line, a meta line and actions. Every native <div> prop except title.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | title | ReactNode | required | The <h1>. | | lead | ReactNode | none | A sentence under the title (text-c3). Rendered in a <p>, so inline content only. | | meta | ReactNode | none | A small muted line (text-c4) for dates, counts or owners. Also a <p>. | | actions | ReactNode | none | Buttons or links on the right. They wrap under the title on narrow screens. |

Notice

Short status text in one of three tones. Every native <p> prop. Type: NoticeTone.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | tone | "info" \| "warning" \| "error" | "info" | text-c3, text-amber-300 or text-rose-300, at text-sm. | | live | boolean | false | Make it a live region: role="alert" for errors, role="status" for the others. A role you pass wins. See below. | | as | "p" \| "div" | "p" | Use "div" for block content such as a list of errors. |

A status region is only reliably announced when its content changes while it is on the page. A live info or warning notice that mounts with its text already inside ({saved && <Notice live>Saved.</Notice>}) can go unannounced. Keep it mounted and change its children, empty while there is nothing to say:

<Notice live>{saved ? "Saved." : ""}</Notice>

An error notice (role="alert") is announced either way.

Text and textClasses (since 0.12.0)

A line of text in a tone and a size, instead of hand-written color classes. Every native <p> prop. Types: TextTone, TextSize, TextClassOptions.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | tone | "default" \| "muted" \| "subtle" \| "error" \| "warning" \| "success" | "default" | c2, c3, c4, rose, amber or emerald. The status tones get one step lighter under more contrast. | | size | "xs" \| "sm" \| "base" | "sm" | The text size. | | bold | boolean | false | font-bold. | | as | "p" \| "span" \| "div" | "p" | The element. |

textClasses({ tone, size, bold, className }) returns the same classes for an element Text can't be (an <output>, a <time>, a class map). Text is styling only, not a live region: a message that appears after an action still goes in Notice live or a mounted role="status". Error and warning text must say what is wrong in words.

<Text tone="muted">Last updated Oct 4.</Text>
<Text role="alert" tone="error" bold>That pack key is not valid.</Text>
<output className={textClasses({ tone: "success" })}>Saved.</output>

Prose

Long-form typography for MDX, docs and legal pages. A max-w-3xl <div> that styles the h2, h3, h4, p, a, strong, ul, ol, li, dl, code, pre, hr, table, figures, task lists, footnotes and details elements inside it (h4, dl, figures, task lists, footnotes and details since 0.17.0). Every native <div> prop. A pre scrolls sideways, so give it tabIndex={0} (through your Markdown renderer's components map) so keyboard users can reach the scroll; CSS can't add that.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | size | "base" \| "sm" | "base" | Since 0.17.0. sm is a denser scale for legal pages and other dense docs; pass it straight through to ContentPage's own proseSize. |

The first element inside gets no top margin ([&>:first-child]:mt-0, since 0.2.0), so a heading that opens the block sits flush with what's above it instead of taking the h2 or h3 gap. The rule reaches direct children only. If you wrap the content in <section>s, the heading at the top of the first section keeps its margin. Reach one level deeper for that:

<Prose className="[&>:first-child>:first-child]:mt-0">
  <section>
    <h2>What we store</h2>
    <p>Your osu! id and your packs.</p>
  </section>
  <section>
    <h2>How long we keep it</h2>
    <p>Until you delete your account.</p>
  </section>
</Prose>

Later sections keep their heading margin, which spaces them apart.

Kbd and kbdClasses

Since 0.17.0. A keyboard key: <kbd> with a small bordered look (rounded border border-b3 bg-b5 font-sans text-c3), shared with the command palette's own shortcut hints. Every native <kbd> prop. kbdClasses is the same classes as a plain string, for a <kbd> (or any element) you build yourself.

<p>Press <Kbd>Ctrl</Kbd>+<Kbd>K</Kbd> to open the palette.</p>
<span className={kbdClasses}>Esc</span>

It also renders for Markdown's kbd element through mdxComponents, and is exported from @haruhimemoe/ui/mdx for <Kbd>Ctrl</Kbd> directly in .mdx source.

Disclosure (client)

Since 0.4.0. A button (at least 24px tall) that shows and hides a panel below it, with aria-expanded and aria-controls and a ▾ / ▴ arrow. The closed panel stays in the page, hidden, so fields inside keep their values. Every native <div> prop except children, for the wrapper.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | summary | ReactNode | required | The button's text. It can change with the state. | | children | ReactNode | required | The panel. | | defaultOpen | boolean | false | Whether it starts open. | | open, onOpenChange | boolean, (open: boolean) => void | none | Control the state from outside. | | buttonClassName, panelClassName | string | none | Classes for the button and the panel, merged last. |

TextLink

Since 0.4.0. A text link: next/link inside the app, a plain <a> off-site (with rel="noreferrer" in a new tab), like ButtonLink. Every next/link prop. Type: TextLinkVariant.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | variant | "accent" \| "plain" | "accent" | accent is h1 and underlined, for links in running text (it doesn't rely on color alone). plain is bold c1, underlined on hover, for a name or title in a list or table. |

<p>
  Scripts can read public packs. <TextLink href="/docs/api">Read the API docs</TextLink>.
</p>
<TextLink href={`/packs/${pack.id}`} variant="plain">{pack.name}</TextLink>

download works the same way (since 0.12.0): <TextLink href="/brand/haruhime-palette.json" download>Download palette (JSON)</TextLink>.

linkClasses

Since 0.4.0. linkClasses({ variant?, className? }): string returns the TextLink classes, for an element that should look like one (a <button> that reads as a link, say). Type: LinkClassOptions. Downloads use TextLink download since 0.12.0.

Badge

Since 0.4.0. A small pill for a status or tag ("Unranked", "beta", a count). A <span> with no role, so screen readers read it in line with the text around it. Every native <span> prop. Type: BadgeTone.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | tone | "neutral" \| "accent" \| "warning" \| "muted" | "neutral" | neutral is b3 with c2 text, accent is h1 with dark bold text, warning is a faint amber with bold amber text, muted is an outlined pill in small bold capitals (a "beta" tag). |

Tabs (client)

Since 0.5.0. A tab list for panels on the same page (use LinkTabs when each tab is its own URL): pill buttons with role="tab" in a role="tablist" named by label. The chosen tab is aria-selected and the only one in the Tab order (the first one when none is chosen). Left and Right move and wrap, Home and End jump to the ends; each picks the tab and focuses it. Controlled. Every native <div> prop except onChange and children, for the tablist. Types: TabItem<T>, TabsProps<T>.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | label | string | required | The tablist's accessible name. | | idPrefix | string | required | Prefix for the tabs' and panels' ids. | | tabs | readonly { id: T; label: ReactNode }[] | required | The tabs, left to right. | | value | T | required | The chosen tab's id. | | onChange | (id: T) => void | required | Gets the picked tab's id. |

The panels are yours. tabId(prefix, tab) and tabPanelId(prefix, tab) (server-safe) give the ids that tie them together (<prefix>-tab-<tab>, <prefix>-panel-<tab>):

<Tabs label="Editor view" idPrefix="ed" tabs={TABS} value={tab} onChange={setTab} />
<div role="tabpanel" id={tabPanelId("ed", tab)} aria-labelledby={tabId("ed", tab)}>...</div>

useMotionAllowed (client, since 0.12.0)

useMotionAllowed() is true when the visitor allows motion. It is false on the server, during hydration, without matchMedia, and while the visitor asks for reduced motion, and it follows the setting live. Use it for motion JavaScript drives (an autoplay video, a canvas, element.animate()). CSS transitions and animations already stop through the theme, so components don't need it for those.

"use client";

import { useMotionAllowed } from "@haruhimemoe/ui";

export function Hero() {
  const motion = useMotionAllowed();
  return motion ? <video autoPlay loop muted playsInline src="/loop.webm" /> : null;
}

Layout

The list and page layout pieces apps used to build by hand (since 0.13.0). All are Server Components except CopyField and SegmentedControl, which are under Forms.

Surface

The list-item box: Card's color and radius at p-3, no heading and no landmark. as picks div (default), li, section, article, form or p; padding is sm (p-3), md (p-4) or lg (p-5). It adds no layout: pass flex flex-col gap-2. A p-0 in className replaces the padding.

<ul className="flex flex-col gap-3">
  <Surface as="li" padding="md" className="flex flex-col gap-1">…</Surface>
</ul>

surfaceClasses

Surface's classes for an element it doesn't render, such as a next/link tile: surfaceClasses({ padding, className }).

LinkCard and CardLink

A card that is one click target. CardLink is the link: its after: box covers the nearest positioned ancestor, and its name is only its own text. LinkCard is the surface (p-5, relative, b3 on hover and focus-within) that the cover fills; every other link, button, field and summary inside is lifted above the cover, so they stay clickable without relative z-10. title plus href renders <h3><CardLink /></h3> first (headingLevel changes the level); without them, put a CardLink in your own heading. media renders first, full-bleed, above the padded body.

<LinkCard media={<img src="/brand/repos/ui-banner.svg" alt="" />}>
  <h2 className="font-extrabold text-xl"><CardLink href="/libraries/ui">@haruhimemoe/ui</CardLink></h2>
  <LinkRow items={links} />
</LinkCard>
  • One CardLink per card: two would stack two covers.
  • The lift sets position: relative on those elements. Something that must overlay another element inside the card stacks by grid area ([grid-area:1/1]), not absolute.
  • CardLink works in any relative box outside LinkCard too; pass after:rounded-[10px] when the box is rounded and doesn't clip.
  • Use Card for a labelled region and LinkCard for a card that goes somewhere.

CardGrid

A grid of cards as a list: one column on phones, columns 2 (default) or 3 from sm/lg, gap md (default, 16px then 20px) or sm (10px), as ul or ol. It wraps each child in a flex <li>, so cards in a row match height: cards render no <li> of their own.

<CardGrid columns={3}>{templates.map((t) => <TemplateCard key={t.id} template={t} />)}</CardGrid>

StatList

Label and value pairs as a <dl>: variant inline (default, a wrapping row), tiles (b4 boxes; on a b4 parent pass className="[&>div]:bg-b3") or grid (2 columns on phones, columns 2, 3 or 4 from sm). Labels are c4 at text-xs, values bold c1 with tabular numbers. Values take any node.

<StatList variant="tiles" items={[{ label: "Maps", value: "12" }, { label: "Avg ★", value: "5.2" }]} />

EmptyState

The "nothing here yet" box: variant dashed (default) or filled, size md (default, centered, p-6) or sm (left, tight), optional title and action. It has no role; announce it with your own live region when it appears after a search.

<EmptyState className="min-h-48">Drop an image here to start.</EmptyState>

Progress

A native <progress> with a name and a status line: label (shown unless hideLabel), value (left out for indeterminate) and max (default 1), status in an <output> under the bar that the bar's aria-describedby points at. The output is always mounted, so a status set later is announced.

<Progress label="Download progress" hideLabel value={ready} max={total} status={`${ready} of ${total} sets ready`} />

LinkRow

A wrapping row of text links (a card's GitHub / npm / Changelog, a page's filters): plain without a label, a named <nav> with one. variant accent (default, bold accent links) or quiet (c2, turns c1 on hover). Mark the current item with current: it gets aria-current="page" and an underline, not color alone. Use LinkTabs for pill links that switch a view of the same page family, ContentNav for a section's vertical nav.

<LinkRow label="Changelog filter" items={[{ href: "/changelog", label: "All" }, { href: "/changelog/kind/packages", label: "Packages", current: true }]} />

SectionHeading

The h2 under a page's h1: bold c1 at text-xl, scroll-mt-20 for a sticky header. level changes the tag (default 2). detail ("(12)") sits inside the heading's accessible name; actions sit to its right; anchor (needs id) adds a # link named after the heading's text.

<SectionHeading id="recent-packs" anchor detail="(12)" actions={<a href="/packs">See all</a>}>Recent public packs</SectionHeading>

PrevNext

Previous and next links at the end of a page in a series (docs tags, guides): a named nav over a b3 rule. Each link reads "Previous: " or "Next: " to a screen reader; a lone next link sits at the end on its own. Renders nothing with neither link.

<PrevNext label="More tags" prev={{ href: "/docs/tags/b", title: "[b] Bold" }} next={{ href: "/docs/tags/i", title: "[i] Italic" }} />

CodeChip

Inline code with a copy button: copy={false} for the code alone; the button's name is Copy <code> unless copyLabel.

<CodeChip code="bun add @haruhimemoe/ui" />

Forms

The fields render a label, the control, an optional hint and an optional error, wired together for screen readers. They are Server Components: you pass the id, so they need no generated ids.

Shared props (type FieldProps), taken by TextInput, Textarea, Select and Checkbox:

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | id | string | required | The control's id. The label points at it. The hint gets <id>-hint and the error <id>-error. | | label | ReactNode | required | The visible label. | | hideLabel | boolean | false | Since 0.12.0. Hides the label visually; it still names the control. No gap is left above the control. | | hint | ReactNode | none | Help text in a <div>, linked with aria-describedby. On Checkbox the hint sits inline inside the label, so keep it to text there. | | error | ReactNode | none | Error text in a role="status" <div> (text-rose-300; role="alert" before 0.7.0), so a list of errors is fine. Sets aria-invalid and links the text with aria-describedby. | | wrapperClassName | string | none | Classes for the wrapper around the label, control, hint and error, for layout (min-w-48 flex-1). |

className goes on the control itself. Your own aria-describedby is kept after the hint and error ids. Fields are 44px tall with 16px text on a coarse pointer (since 0.12.0).

TextInput

Every native <input> prop (type, name, value, onChange, placeholder, required...), plus the field props.

<TextInput id="pack-name" label="Name" hint="Shown on the pack page." required />

Textarea

Every native <textarea> prop, plus the field props. At least min-h-24 tall, resizes vertically.

Select

Every native <select> prop, plus the field props. Pass <option> elements as children.

Checkbox

Every native <input> prop except type (checked, defaultChecked, onChange, name, disabled...), plus the field props. The box is 24px (since 0.8.0; it was the browser's 13px), the label is bold text-c1 and the hint follows it inline after a dot. Clicking anywhere on the row toggles it. The label alone is the accessible name; the hint is the description. An aria-labelledby you pass is added after the label (since 0.4.0; 0.3.0 dropped it).

RadioGroup (client)

Since 0.4.0. A native radio group on the Checkbox look: a <fieldset> named by its <legend>, one 24px radio per option with a bold label and an inline hint, then the group's hint and error. Each option's label is its accessible name and its hint its description. Arrow keys move and pick, as native radios do. Every native <fieldset> prop except onChange, children and defaultValue; disabled turns off every radio. Types: RadioOption.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | label | ReactNode | required | The legend. | | hideLabel | boolean | false | Since 0.12.0. Hides the legend visually; it still names the group. | | options | readonly RadioOption[] | required | { value: string; label: ReactNode; hint?: ReactNode; disabled?: boolean } for each radio. | | value / defaultValue | string | none | The picked value, held by you (value, with onChange) or by the group (defaultValue). | | onChange | (value: string) => void | none | Gets the picked option's value. | | name | string | generated | The radios' name, for a form. | | hint, error | ReactNode | none | Under the options, linked to the group with aria-describedby. An error is a role="status" line (since 0.7.0; before, role="alert" and aria-invalid on each radio). | | required | boolean | false | Every radio gets required. |

TypeToConfirm (client)

Since 0.4.0. A confirm for something that can't be undone: a <form> whose submit button stays off until the expected text is typed exactly (spaces around it don't count). The field has no autocomplete, autocapitalize or spellcheck. Enter submits once it matches. Children show above the field, to say what the action does. Every native <form> prop except onSubmit.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | id | string | required | The text field's id, as on TextInput. | | expected | string | required | What has to be typed. | | label | ReactNode | "Type <expected> to confirm" | The field's label. | | hint, error | ReactNode | none | As on TextInput. Pass error when the action fails. | | submitLabel | ReactNode | required | The button's text. | | pendingLabel | ReactNode | submitLabel | The button's text while onConfirm runs. It runs once at a time. | | onConfirm | () => void \| Promise<void> | required | Runs on submit once the text matches. If it throws or rejects, the form stays as typed. | | variant | "primary" \| "secondary" \| "ghost" \| "danger" | "secondary" | The button's look. |

<TypeToConfirm id="delete-pool" expected={pool.name} submitLabel="Delete this pool" onConfirm={remove} error={error}>
  <p>This deletes the pool for everyone who edits it. It can't be undone.</p>
</TypeToConfirm>

In a dialog, use ConfirmDialog's typeToConfirm (see Which confirm).

CharCounter

Since 0.5.0. How much of a length limit a text uses: "1,234 / 60,000 characters" in c3, then bold rose with ": 1,500 over the limit" once past it (at the limit is not over). You count, so any rule works (a string's length, a BBCode counter). Every native <p> prop except children.

With live (since 0.7.0) an <output> live region inside it announces "1,500 over the limit" when the count goes over and nothing while under, so typing isn't read out number by number.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | count | number | required | How many are used. | | limit | number | required | The most allowed. | | unit | string | "characters" | What is counted. | | live | boolean | false | Announce the over-limit text to screen readers (since 0.7.0). |

LocaleSelect (client, since 0.27.0)

A language picker for apps without locale prefixes in their URLs. It lists locales, each named in its own language (localeName("ja") is "日本語"). Picking one writes the NEXT_LOCALE cookie (on cookieDomain when set, like ".haruhime.moe", so every app follows), calls onChange and refreshes the page. With fewer than two locales it renders nothing, so an app can mount it before it has translations.

<LocaleSelect locales={LOCALES} value={locale} label={t("language")} cookieDomain=".haruhime.moe" />

Props: locales, value, label (default "Language"), hideLabel (default true), cookieDomain, onChange, className. writeLocaleCookie(locale, domain?) is the cookie write on its own.

VisibilitySelect (client)

Since 0.5.0. Who can see something: private, unlisted or public, each with a line that says who that is. As radios (a RadioGroup, every line shown as its option's description) or as a native select (a Select, the picked option's line as the hint). Controlled. Also exported: VISIBILITIES (["private", "unlisted", "public"]), the Visibility type, VisibilityText ({ label: string; hint?: ReactNode }) and VISIBILITY_TEXT, the default words ("Only you can see it.", "Anyone with the link can see it. It isn't listed.", "Anyone can see it, and it's listed.").

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | value | Visibility | required | The picked visibility. | | onChange | (value: Visibility) => void | required | Gets the new one. | | label | ReactNode | "Who can see it" | The legend, or the select's label. | | as | "radio" \| "select" | "radio" | Radios, or a dropdown. | | text | Partial<Record<Visibility, Partial<VisibilityText>>> | none | Your words, merged over VISIBILITY_TEXT per visibility. | | id | string | generated | The select's id, or the radios' name. | | hint | ReactNode | none | Under the radios. On a select it replaces the picked option's line. | | error | ReactNode | none | As on the fields. | | disabled | boolean | false | Turns it off. | | className | string | none | Classes for the fieldset, or the select's wrapper. |

<VisibilitySelect
  label="Who can see this pool"
  value={visibility}
  onChange={setVisibility}
  text={{ private: { hint: "Only you and your editors." } }}
/>

ReportDisclosure (client)

Since 0.5.0. "Report this": a Disclosure holding a reason Textarea (required) and a submit button. onSubmit gets the trimmed reason and says how it went: { ok: true, message? } replaces the form with a role="status" line (your message, like "You already reported it.", or sentMessage), which takes focus, since the button that had it is gone (since 0.7.0; the line is mounted empty from the start, so it is announced); { ok: false, message } shows the message as the field's error and keeps what was typed for a retry. A throw reads as failedMessage. One send at a time. Type: ReportResult.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | onSubmit | (reason: string) => Promise<ReportResult> | required | Sends the report. | | summary | ReactNode | "Report" | The disclosure button's text. | | label | ReactNode | "What's wrong with it?" | The reason field's label. | | hint | ReactNode | none | Help under the field. | | submitLabel, pendingLabel | ReactNode | "Send report", "Sending…" | The button's text, and while sending. | | sentMessage | ReactNode | "Thanks. Your report was sent." | Said when the result has no message. | | failedMessage | ReactNode | "Couldn't send the report. Try again." | Said when onSubmit throws. | | minLength, maxLength | number | 3, none | The reason's length limits. | | rows | number | 3 | The field's height. | | className | string | none | Classes for the wrapper around the disclosure and the status line. |

<ReportDisclosure
  summary="Report this template"
  maxLength={500}
  onSubmit={async (reason) => {
    const response = await fetch(`/api/templates/${id}/report`, { method: "POST", body: JSON.stringify({ reason }) });
    return response.ok ? { ok: true } : { ok: false, message: "Reporting failed." };
  }}
/>

fieldClasses

fieldClasses(className?: string): string returns the field look (b6 background, b3 border, h1 border plus the theme's 2px h1 outline on focus, rose border when aria-invalid, and the h1 border back when an invalid field has focus; before 0.7.0 the outline was hidden and only the border changed). Use it on a bare control that labels itself:

<select aria-label="Move to" className={fieldClasses("w-auto")}>...</select>

CopyField (client)

A read-only field to copy from: the value in mono (mono={false} to turn it off), focus selects all of it, then a row with a Copy button and your actions. The label to input gap is 8px. ref, hint, error and hideLabel reach the input; wrapperClassName places the whole field. The Copy button is described by the label, so two fields read "Copy, Pack key" and "Copy, Short link". A copy that depends on something only the browser knows (a share link with window.location.origin) goes in actions as its own CopyButton.

<CopyField label="Pack key" value={packKey} copyLabel="Copy key" copyVariant="primary" copiedMessage="Key copied."
  actions={<CopyButton text={shareLink} disabled={!origin} label="Copy share link" />} />

SegmentedControl (client)

A two to four way view switch drawn like a pill track, built on native radios: Tab lands on the checked one, arrows move and pick. Radios, not links: use LinkTabs when each choice is its own URL, ChoiceChips for a filter among other chips. hideLabel keeps the group named through an sr-only legend; size is sm (24px) or md (default, 32px).

<SegmentedControl label="Preview size" hideLabel size="sm" options={[{ value: "fit", label: "Fit" }, { value: "actual", label: "Actual size" }]} value={scale} onChange={setScale} />

Actions

CopyButton (client)

A button that copies text, with the result in an <output> beside it that screen readers announce. Each press clears the message first, so a second copy is announced too. Only the latest press reports: an earlier copy that settles later (behind a permission prompt, say) doesn't overwrite it (since 0.4.0). If the clipboard is missing or refuses (an insecure page, say), it shows the failure message. Every Button prop except onClick and children; className and the native props go on the button.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | text | string | required | The text to copy. | | label | ReactNode | "Copy" | The button's text. | | copiedMessage | ReactNode | "Copied." | Shown after a copy works. | | failedMessage | ReactNode | "Couldn't copy. Select the text and copy it by hand." | Shown when it doesn't. | | variant | "primary" \| "secondary" \| "ghost" | "secondary" | As on Button. | | size | "md" \| "lg" | "md" | As on Button. | | wrapperClassName | string | none | Classes for the wrapper around the button and the message. | | statusPosition | "end" \| "start" | "end" | Since 0.16.0. "start" puts the status before the button instead of after it. | | reserveStatus | boolean | false | Since 0.16.0. Keeps the status's width (as wide as "Copied.") so a press never moves the button. |

Pagination

Previous and next pills around "Page X of Y": links (hrefFor), or since 0.4.0 buttons (onPageChange). Renders nothing when there is one page or none. Every native <nav> prop except children; aria-label defaults to "Pages".

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | page | number | required | The current page, starting at 1. | | pageCount | number | required | How many pages there are. | | hrefFor | (page: number) => string | one of the two | Link mode: builds a page's URL, e.g. (p) => `/packs?page=${p}`. | | onPageChange | (page: number) => void | one of the two | Button mode (since 0.4.0): called with the page to show, for results fetched in place. pageCount may be null there. | | hasNext | boolean | false | Button mode with pageCount={null}: whether a page comes after this one. | | previousLabel | ReactNode | "Previous" | Text of the link to the page before. | | nextLabel | ReactNode | "Next" | Text of the link to the page after. | | formatStatus | (page: number, pageCount: number) => ReactNode | "Page X of Y" | The text in the middle. In button mode pageCount can be null, and the default reads "Page X". |

A page or pageCount straight from a URL is safe to pass (since 0.4.0): NaN reads as page 1 (and a NaN count as one page), a page past either end is pulled back inside, and fractions are dropped. Number("abc") shows page 1 with a Next link, and page 99 of 5 shows page 5.

The links use next/link with rel="prev" and rel="next". On the first and last page one link goes away. If it had keyboard focus (Next pressed on page 4 of 5), focus moves to the "Page X of Y" text instead of falling back to the top of the page. Pagination stays a Server Component; that text is a small client component inside it.

In button mode, Previous and Next are <button>s. At the first or last page they stay in place, dimmed, with aria-disabled="true": they keep focus and ignore presses. The status is a polite live region there, so each new page is announced.

<Pagination page={page} pageCount={null} hasNext={data.hasMore} onPageChange={setPage} />

AsyncButton (client)

Since 0.4.0. A button that runs an async action and reports how it went in an <output> beside it that screen readers announce, like CopyButton. While it runs, the button keeps focus but ignores presses (aria-disabled). Every Button prop except onClick; the children are its text.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | action | () => ReactNode \| Promise<ReactNode> | required | Runs on press. What it returns is the message ("Done."). | | pendingLabel | ReactNode | the children | The button's text while the action runs. | | failedMessage | ReactNode \| ((error: unknown) => ReactNode) | "Something went wrong. Try again." | Shown in text-rose-300 when the action throws or rejects. | | wrapperClassName | string | none | Classes for the wrapper around the button and the message. |

InlineConfirm (client)

Since 0.4.0. A two-step confirm in the page, no dialog: a trigger button, then the question with a cancel and a confirm button in its place. Opening moves focus to cancel, the safe choice. Cancel or Escape closes it and puts focus back on the trigger, and so does a confirm that resolves. The open confirm is a <fieldset> (a group) named by the question, so screen readers hear the question when focus arrives. Every native <div> prop except children, for the wrapper.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | trigger | ReactNode | required | The first button's text. | | question | ReactNode | required | Shown once the trigger is pressed. | | onConfirm | () => void \| Promise<void> | required | Runs on confirm. While it runs, both buttons keep focus but ignore presses. If it throws or rejects, the confirm stays open: show the error yourself. | | confirmLabel, cancelLabel | ReactNode | "Confirm", "Cancel" | The two buttons' text. | | pendingLabel | ReactNode | confirmLabel | The confirm button's text while onConfirm runs. | | onCancel | () => void | none | Called when it closes without confirming. | | triggerProps | ButtonProps | none | Props for the trigger (aria-label, variant, disabled). It is secondary by default. | | confirmVariant | "primary" \| "secondary" \| "ghost" \| "danger" | "secondary" | The confirm button's look. Cancel is always ghost. |

If a confirm removes the item (and the InlineConfirm with it), move focus somewhere sensible yourself, such as the list's heading. For a delete that affects other people or sits in a table cell, use ConfirmDialog (see Which confirm).

Dialogs

Since 0.14.0. A modal base and a confirm built on it. Both are client components: their callbacks come from your own "use client" file.

Dialog (client)

The bare modal: a native <dialog> that follows open. Opening records what had focus, runs showModal() (the rest of the page goes inert), focuses initialFocus and locks the page's scroll. Closing puts focus back on what opened it, or on returnFocus() when that has left the page, and puts the page's own inline overflow back. Unmounting while open does the same and asks no one. Nothing closes by itself: Escape and a backdrop press call onDismiss, and you set open to false. A backdrop press counts only when it starts and ends on the backdrop, so a text selection dragged out of the dialog never closes it. When the browser closes it itself (a back gesture), onDismiss("browser") tells you to follow. A dialog opened from inside one that closes at the same time (a palette command) sends focus back to that one's opener. It fades in (motion); close is instant, and reduced motion shows it at once. Every native <dialog> prop except open, onCancel and onClose. Name it with aria-label or aria-labelledby.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | open | boolean | required | Controlled. true runs showModal(), false runs close(). | | onDismiss | (reason: "escape" \| "backdrop" \| "browser") => void | required | The person or the browser asked to close. On "browser" it is already closed: set open to false. | | initialFocus | RefObject<HTMLElement \| null> | the browser's rule | Focused after opening. | | returnFocus | () => HTMLElement \| null | none | Where focus goes on close when the opener is gone (a deleted row). | | dismissible | boolean | true | false ignores Escape and the backdrop, for while an action runs. | | motion | boolean | true | The open fade. | | lockScroll | boolean | true | Lock the page's scroll while open. Locks are counted, so stacked dialogs share one. |

The <dialog> is the backdrop's hit area: make it cover the viewport and put your panel inside it, or a press on the panel's own padding counts as a backdrop press.

"use client";

const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>Preview</Button>
<Dialog
  open={open}
  onDismiss={() => setOpen(false)}
  aria-labelledby="preview-title"
  className="m-0 h-dvh max-h-none w-full max-w-none open:flex"
>
  <div className="m-auto w-[calc(100%-2rem)] max-w-md rounded-xl border border-b3 bg-b6 p-5">
    <h2 id="preview-title" className="font-bold text-lg">Template preview</h2>
    <Button onClick={() => setOpen(false)}>Close</Button>
  </div>
</Dialog>

ConfirmDialog (client)

A confirm in a modal alertdialog, on Dialog. The title names it and the description describes it. Cancel (ghost) and Confirm sit in a form, so Enter submits. Below sm the buttons go full width with Confirm on top. Focus starts on Cancel, the safe choice, or on the type field with typeToConfirm. While onConfirm runs (once at a time), both buttons keep focus but ignore presses, Confirm shows pendingLabel, and Escape and the backdrop do nothing. When it resolves the dialog closes and focus goes back to the trigger, or to returnFocus() if the trigger is gone. When it throws or rejects the dialog stays open, keeps what was typed, and says failedMessage in an alert. Content renders only while open, so each open starts fresh.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | title | ReactNode | required | The heading. Names the dialog. | | description | ReactNode | none | What happens, under the title. Describes the dialog. | | children | ReactNode | none | Extra content above the buttons (a list of what goes). | | onConfirm | () => void \| Promise<void> | required | Resolve closes it. Throw to keep it open: throw new Error(answer.message). | | failedMessage | ReactNode \| ((error: unknown) => ReactNode) | "Something went wrong. Try again." | Shown in an alert on failure. (e) => (e as Error).message shows what you threw. | | tone | "default" \| "destructive" | "default" | "destructive" makes Confirm the danger button. | | typeToConfirm | string \| { expected, label?, hint? } | none | Confirm stays aria-disabled until this is typed: exact, case-sensitive, spaces around it ignored (TypeToConfirm's rule). The label defaults to "Type <expected> to confirm". | | confirmLabel, cancelLabel | ReactNode | "Confirm", "Cancel" | The two buttons' text. | | pendingLabel | ReactNode | confirmLabel | Confirm's text while onConfirm runs. | | onCancel | () => void | none | Called when it closes without confirming. | | returnFocus | () => HTMLElement \| null | none | Where focus goes when the trigger is gone after a confirm. | | trigger, triggerProps | ReactNode, ButtonProps | none | Uncontrolled: a secondary Button that opens it. | | open, onOpenChange | boolean, (open: boolean) => void | none | Controlled, instead of trigger: for palette commands and rows that own the state. |

<ConfirmDialog
  trigger="Delete this pool"
  triggerProps={{ variant: "danger" }}
  title={`Delete ${pool.name}?`}
  description="This deletes the pool for you and everyone who edits it. It can't be undone."
  tone="destructive"
  typeToConfirm={pool.name}
  confirmLabel="Delete for good"
  pendingLabel="Deleting…"
  failedMessage={(error) => (error as Error).message}
  onConfirm={async () => {
    const answer = await deletePool(pool.id);
    if (!answer.ok) throw new Error(answer.message);
  }}
/>

Which confirm

Pick by what is lost and who loses it, then by where the trigger sits.

  1. No confirm. The change can be undone in place (an Undo, Ctrl+Z), it only touches an unsaved form, or redoing it costs a click. Never confirm something that has an Undo.
  2. InlineConfirm. It can't be undone, touches only your own things, and the consequence fits one short sentence. The trigger sits in a row or toolbar with room to grow.
  3. ConfirmDialog. It can't be undone, and it affects other people (shared links, editors, someone else's content), or the consequence needs more than one sentence, or the trigger sits where inline growth breaks the layout (a table cell, a header menu, a palette command).
  4. ConfirmDialog with typeToConfirm. It can't be undone and it deletes an account, deletes a thing other people edit, or gives ownership away. Type the account's or the thing's name.
  5. TypeToConfirm. Only when the confirm is the whole page, or a step in a flow with nothing else on screen.

Never window.confirm(), and never a hand-built modal: build on Dialog.

Icons

DiscordIcon

Since 0.3.0. The Discord logo as an inline SVG in the current text color. Hidden from screen readers by default (aria-hidden="true"), so put a label on the link around it. Every native <svg> prop except children and viewBox.

The path is Simple Icons' discord.svg at tag 16.32.0 (simple-icons/simple-icons, CC0 1.0). Discord is a trademark of Discord Inc. Its brand guidelines ask for the logo in color, black or white, and not recolored. The icon takes the current text color, so give the element around it one of those (SiteFooter uses white).

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | className | string | "size-5" | Replaces the default size. |

GitHubIcon

The GitHub mark as an inline SVG in the current text color. Hidden from screen readers by default (aria-hidden="true"), so put a label on the link around it. Every native <svg> prop except children and viewBox.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | className | string | "size-5" | Replaces the default size. |

HaruhimeWordmark

The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and pink whatever --hue is; under theme-light.css the white becomes c1 ink (--wordmark-ink, since 0.20.0). Every native <svg> prop except children and viewBox.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | title | string | "haruhime.moe" | The accessible name (role="img" with a <title>). | | decorative | boolean | false | Hide it from screen readers, for use inside a labelled link. | | className | string | "h-6 w-auto" | Replaces the default size. The width follows the height. |

HaruhimeWordmarkLink

A plain <a> around a decorative HaruhimeWordmark, dimmed until hovered. Every native <a> prop except children.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | href | string | "https://www.haruhime.moe" | Where it links. | | aria-label | string | "haruhime.moe" | The link's accessible name. | | wordmarkClassName | string | "h-6 w-auto" | Replaces the wordmark's size. |

Filters

The osu! beatmap listing layout: a panel of rows, each with a label on the left and controls on the right. The interactive pieces take callbacks, so render them from a "use client" file:

"use client";

import {
  ChipGroup,
  type ChipOption,
  FilterPanel,
  FilterRow,
  RangeSlider,
  type RangeSliderValue,
} from "@haruhimemoe/ui";
import { useState } from "react";

const MODS: ChipOption[] = [
  { value: "HD", label: "HD" },
  { value: "HR", label: "HR" },
  { value: "DT", label: "DT" },
];

export function PackFilters({ count }: { count: number }) {
  const [mods, setMods] = useState<string[]>([]);
  const [stars, setStars] = useState<RangeSliderValue>([0, null]);
  const active = mods.length > 0 || stars[0] > 0 || stars[1] !== null;

  return (
    <FilterPanel
      title="Filters"
      resultCount={`${count} packs`}
      active={active}
      onClear={() => {
        setMods([]);
        setStars([0, null]);
      }}
    >
      <FilterRow label="Mods">
        <ChipGroup label="Mods" hideLabel options={MODS} value={mods} onChange={setMods} />
      </FilterRow>
      <FilterRow label="Star rating">
        <RangeSlider
          label="Star rating"
          hideLabel
          min={0}
          max={10}
          step={0.1}
          openEnded
          value={stars}
          onChange={setStars}
        />
      </FilterRow>
    </FilterPanel>
  );
}

Give the ChipGroup or RangeSlider inside a FilterRow hideLabel. The row's label then shows once, and screen readers hear the row's name once instead of two nested groups with the same name.

Chip (client)

A toggle pill: a <button> with aria-pressed, h1 when on. Every native <button> prop except aria-pressed, which pressed sets.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | pressed | boolean | required | Whether it is on. | | onPressedChange | (pressed: boolean) => void | none | Called with the new state on click, Enter or Space. | | type | "button" \| "submit" \| "reset" | "button" | Never submits a form by default. | | unavailableReason | ReactNode | none | Since 0.4.0. Why the chip can't be pressed right now (EZ with HR picked). The chip is dimmed and never toggles, but stays in the tab order (aria-disabled="true", not disabled), so keyboard and screen reader users find it and hear the reason as its description. A string reason is also its title. |

Your own onClick runs first. Call event.preventDefault() in it to skip the toggle.

ChipGroup (client)

A labelled row of chips for picking several values (mods, game modes). A <fieldset>; every native <fieldset> prop except onChange and children. disabled turns off every chip.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | label | ReactNode | required | Names the group. | | hideLabel | boolean | false | For use inside a FilterRow, which names the row: the label doesn't render and the fieldset isn't a group of its own (role="none"). disabled still reaches every chip. | | options | readonly ChipOption[] | required | { value: string; label: ReactNode; disabled?: boolean; unavailableReason?: ReactNode } for each chip (unavailableReason since 0.4.0, as on Chip). | | value | readonly string[] | required | The picked values. | | onChange | (value: string[]) => void | required | Gets the new picked values, in the options' order, without duplicates. |

ChoiceChips (client)

Since 0.4.0. One choice from a few, as a real radio group drawn as chips (a status or type filter): native radios under one name, so Tab reaches the checked chip and the arrow keys move and pick. Same look as Chip, with the focus ring on the chip. A <fieldset> like ChipGroup, with the same label and hideLabel; every native <fieldset> prop except onChange, children and defaultValue. Type: ChoiceChipOption.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | label | ReactNode | required | Names the group. | | hideLabel | boolean | false | As on ChipGroup, inside a FilterRow. | | options | readonly ChoiceChipOption<T>[] | required | { value: T; label: ReactNode; disabled?: boolean } for each chip. | | value | T | required | The picked value. | | onChange | (value: T) => void | required | Gets the picked value. | | name | string | generated | The radios' name, for a form. |

For a two to four way view switch drawn as a pill track, use SegmentedControl.

RangeSlider (client)

Two thumbs on one track with an editable box at each end, for star rating, length or BPM. A <fieldset>; every native <fieldset> prop except onChange, children and inputMode. Type: RangeSliderValue ([number, number | null]), so useState<RangeSliderValue> can pass its setter straight in.

| Prop | Type | Default | What it does | | --- | --- | --- | --- | | label | string | required | Names the group, and the ends as "Minimum label" and "Maximum label". | | hideLabel | boolean | false | For use inside a FilterRow, which names the row: the label doesn't show and the fieldset isn't a group of its own (role="none"). The ends keep their "Minimum label" and "Maximum label" names. | | `mi