@pitcher/react-ui
v1.5.0
Published
Shared React component library for the Pitcher platform. Built on [Shadcn UI](https://ui.shadcn.com/) + [Radix](https://www.radix-ui.com/) primitives + [Tailwind CSS](https://tailwindcss.com/), it provides the Pitcher design system (colors, typography, ic
Keywords
Readme
@pitcher/react-ui
Shared React component library for the Pitcher platform. Built on Shadcn UI + Radix primitives + Tailwind CSS, it provides the Pitcher design system (colors, typography, icons) for all React-based apps.
Installation
npm (standalone apps)
npm install @pitcher/react-uiWeb monorepo
// package.json
{
"dependencies": {
"@pitcher/react-ui": "workspace:*"
}
}Setup
1. Tailwind config
// tailwind.config.ts
import { pitcherPreset } from '@pitcher/react-ui/tailwind'
export default {
presets: [pitcherPreset],
content: [
'./src/**/*.{ts,tsx}',
// REQUIRED — Tailwind must scan library source for class names
'./node_modules/@pitcher/react-ui/src/**/*.{ts,tsx}', // npm
// or for monorepo (path relative to your package):
// '../libs/react-ui/src/**/*.{ts,tsx}', // from packages/next-core/
// '../../libs/react-ui/src/**/*.{ts,tsx}', // from packages/apps/*/
],
}Important: The
contentarray must include the react-ui source path. Without it, Tailwind purges all classes that only appear in library components.
2. CSS
Import the global stylesheet before Tailwind directives in your main CSS file:
/* globals.css */
@import '@pitcher/react-ui/styles';
@tailwind base;
@tailwind components;
@tailwind utilities;This provides:
- CSS custom properties for light and dark mode (Pitcher palette + Shadcn tokens)
- Font Awesome Pro icon fonts (regular, solid, light, brands)
3. Framework-specific config
Next.js — add to next.config.ts:
const nextConfig = {
transpilePackages: ['@pitcher/react-ui'],
}Vite — no extra config needed.
Usage
import { Button, FaIcon, Spinner, cn } from '@pitcher/react-ui'
function SaveButton() {
return (
<Button aria-label="Save changes">
<FaIcon icon="fa-regular fa-floppy-disk" className="mr-2" />
Save
</Button>
)
}Package Exports
| Import path | What it provides |
|---|---|
| @pitcher/react-ui | All components, hooks, and the cn() utility |
| @pitcher/react-ui/tailwind | pitcherPreset — Tailwind preset with Pitcher palette, border radius, animations |
| @pitcher/react-ui/styles | Global CSS — CSS custom properties (light + dark) + Font Awesome icon fonts |
| @pitcher/react-ui/fonts/* | Raw Font Awesome font files (woff2, css) — only needed if CSS import path issues arise |
Components
Core
| Component | Description |
|---|---|
| Button | Primary, secondary, outline, ghost, link, destructive variants |
| ButtonGroup | Grouped button toolbar with separator |
| Badge | Status labels and counts |
| Tag | Colored tag/chip (getTagColorForString for stable per-string colors) |
| Spinner | Loading indicator |
| GlobalLoader | Branded full-screen (or fitParent) loader with the animated Pitcher mark, optional label/description |
| FaIcon | Font Awesome icon wrapper (<FaIcon icon="check" type="fas" />) |
| Kbd | Keyboard shortcut display |
| Empty | Empty state placeholder |
Layout
| Component | Description |
|---|---|
| Card | Container with header, content, footer sections |
| AspectRatio | Constrained aspect ratio container |
| Separator | Horizontal/vertical divider |
| ScrollArea | Custom scrollbar container |
| ResizablePanelGroup | Draggable resizable panels |
| Sidebar | Application sidebar with menu, groups, collapsible sections |
Navigation
| Component | Description |
|---|---|
| Tabs | Tab panels |
| Accordion | Collapsible content sections |
| Breadcrumb | Breadcrumb trail |
| NavigationMenu | Top-level navigation with dropdowns |
| Pagination | Page navigation |
Form
| Component | Description |
|---|---|
| Input, Textarea | Text inputs |
| InputGroup | Input with addons, buttons, or text segments |
| InputOTP | One-time password input |
| Select, MultiSelect | Single and multi-select dropdowns |
| FilterSelect | Filter dropdown (single or multi mode) |
| Checkbox, Switch, RadioGroup | Toggle controls |
| Slider | Range slider |
| Label | Form label |
| Calendar, DateTimePicker, TimePicker | Date/time pickers |
| Form, FormField, FormItem | Form layout with react-hook-form integration |
| StarRating | Star rating input |
Feedback
| Component | Description |
|---|---|
| Alert | Inline alert with title, description, actions, close button |
| Progress | Progress bar |
| Skeleton | Loading placeholder |
| Toast + Toaster | Radix-based toast notifications (use with useToast()) |
| SonnerToaster | Sonner-based toast notifications (use with toast() from sonner) |
Overlays
| Component | Description |
|---|---|
| Dialog | Modal dialog |
| AlertDialog | Confirmation dialog with cancel/confirm |
| Sheet | Slide-out panel (top, right, bottom, left) |
| Drawer | Bottom drawer (mobile-friendly, powered by vaul) |
| Popover | Popover with arrow |
| HoverCard | Card shown on hover |
| Tooltip | Tooltip on hover/focus |
Menus
| Component | Description |
|---|---|
| DropdownMenu | Dropdown menu with items, checkboxes, radio groups, submenus |
| ContextMenu | Right-click context menu |
| Menubar | Horizontal menu bar |
| Command | Command palette / search (cmdk) |
Data Display
| Component | Description |
|---|---|
| Table | Data table with header, body, footer, caption |
| Avatar | User avatar with image + fallback |
| Carousel | Carousel slider (embla) |
| ChartContainer | Recharts wrapper with Pitcher theme |
Toggle
| Component | Description |
|---|---|
| Toggle | Toggle button |
| ToggleGroup | Exclusive or multi-select toggle group |
| Collapsible | Expand/collapse content |
Hooks
| Hook | Description |
|---|---|
| useToast() | Radix toast — returns { toast, dismiss, toasts } |
| useIsMobile() | Responsive breakpoint detection |
| useSidebar() | Sidebar open/close state |
| useFormField() | Form field context (errors, validation state) |
| useInstanceColor() | Applies an instance's primary branding color to the theme CSS variables |
Toast Systems
The library includes two independent toast systems. Each requires its own provider component mounted in your React tree:
Radix Toast (more control, custom actions)
import { Button, Toaster, ToastAction, useToast } from '@pitcher/react-ui'
function App() {
const { toast } = useToast()
return (
<>
<Button onClick={() => toast({
title: 'Saved',
description: 'Your changes have been saved.',
action: <ToastAction altText="Undo">Undo</ToastAction>,
})}>
Save
</Button>
<Toaster /> {/* Required — renders Radix toasts */}
</>
)
}Sonner Toast (simpler API, built-in variants)
import { Button, SonnerToaster } from '@pitcher/react-ui'
import { toast } from 'sonner'
function App() {
return (
<>
<Button onClick={() => toast.success('Saved!')}>Save</Button>
<SonnerToaster /> {/* Required — renders Sonner toasts */}
</>
)
}Color Palette
The Tailwind preset exposes two color systems via CSS custom properties (light + dark mode):
Shadcn Semantic Tokens
Standard Shadcn colors mapped to the Pitcher palette:
bg-primary, bg-secondary, bg-destructive, bg-muted, bg-accent, bg-card, bg-popover, text-foreground, text-muted-foreground
Pitcher Palette (p-* prefix)
Direct access to all Pitcher design system colors. Hexes below are the light-mode
--p-* triplets declared in src/styles/globals.css — the only source of truth for what
this package actually paints. They are close to but not identical with canvas-ui's
UnoCSS palette (light.const.ts), which is where the almost-matching hexes quoted in
web/CLAUDE.md come from; do not "correct" one table against the other.
| Token | Light | Usage |
|---|---|---|
| p-primary | #29335B | Primary brand |
| p-primary2 | #4E5983 | Primary variant |
| p-primary3 | #7C87B1 | Primary light |
| p-primary4 | #BEC4DF | Primary lighter |
| p-primary5 | #DFE1EC | Primary subtle |
| p-primary6 | #F8F9FC | Primary background |
| p-error | #BB1637 | Error / destructive |
| p-error2 | #FEF7F5 | Error background |
| p-success | #4FA30F | Success |
| p-success2 | #F7FFE5 | Success background |
| p-warning | #E3BE02 | Warning |
| p-warning2 | #FDF8CE | Warning background |
| p-info | #44B2DA | Info |
| p-info2 | #F5FDFF | Info background |
| p-text | #212121 | Primary text |
| p-text2 | #757575 | Secondary text |
| p-text3 | #BDBDBD | Tertiary text |
| p-base | #FFFFFF | Base background |
| p-error-fg | #6E072D | Readable text on p-error2 |
| p-success-fg | #2B6B06 | Readable text on p-success2 |
| p-warning-fg | #856600 | Readable text on p-warning2 |
| p-info-fg | #085E7D | Readable text on p-info2 |
| p-scrim | #000000 @ 80% | Modal / overlay scrim |
// Examples
<div className="bg-p-primary6 text-p-text border border-p-primary5">
<span className="text-p-error">Error message</span>
<span className="text-p-success">Success</span>
</div>Dark mode values are automatically applied when the dark class is present on a parent element.
Which tokens are fills, and which are text colours
Not every palette token is legible as text. Four of them are fill-only: they
exist to paint a chip, a bar or a dot, and putting a text- prefix on them
produces text that fails WCAG AA. Ratios below are measured against p-base
(#FFFFFF) from the actual token values in src/styles/globals.css:
| Token | Hex | vs. white | Safe as text? | Use it for |
|---|---|---:|---|---|
| p-warning | #E3BE02 | 1.81:1 | ❌ fill only | backgrounds, dots, bars |
| p-info | #44B2DA | 2.44:1 | ❌ fill only | backgrounds, dots, bars |
| p-text3 | #BDBDBD | 1.88:1 | ❌ decorative | dividers, disabled marks |
| p-success | #4FA30F | 3.18:1 | ⚠️ icons/UI only | icons, borders (1.4.11) |
| p-error | #BB1637 | 6.39:1 | ✅ | text and fills |
| p-text2 | #757575 | 4.59:1 | ✅ on white only | secondary text on p-base |
| p-primary | #29335B | 12.18:1 | ✅ | text and fills |
On a -2 tint, the readable foreground is the matching -fg token:
| Tint | Foreground | Ratio |
|---|---|---:|
| bg-p-error2 | text-p-error-fg | 11.44:1 |
| bg-p-success2 | text-p-success-fg | 6.40:1 |
| bg-p-warning2 | text-p-warning-fg | 5.04:1 |
| bg-p-info2 | text-p-info-fg | 7.05:1 |
Why
-fgand not-3?-3is not a consistent "dark step". It is dark for error (#6E072D), success (#2B6B06) and warning (#856600) — butp-info3is an 88%-lightness tint (#C8EFF9, 1.22:1 on white), so before PIT-8349 the palette had no readable info foreground at all and consumers borrowedtext-p-primaryfor it.--p-error-fg/--p-success-fg/--p-warning-fgalias their-3step unchanged;--p-info-fgis the one new colour. Reach for-fg, not-3— the numeric steps are kept only for compatibility.
Overlay scrim
bg-p-scrim is the modal/overlay scrim. The alpha is inside the token
(--p-scrim: 0 0% 0% / 0.8), so use it bare — an opacity modifier
(bg-p-scrim/50) produces invalid CSS:
<DialogOverlay className="…" /> {/* already paints bg-p-scrim */}/* Retune every overlay in the app at once — never per dialog. */
:root { --p-scrim: 0 0% 0% / 0.5; }⚠️ Token spelling: the p namespace has no hyphen, the aliases do
Both wrong spellings compile and emit no CSS at all, with no error
anywhere. ⚠️ That does not mean the element renders colourless — no selector
is generated, so it keeps whatever colour it already computed, from inheritance
or from an earlier rule. The failure is therefore silently the wrong colour,
which is harder to spot than a missing one. (Contrast a dead border-*, where
preflight's border-width: 0 turns the same non-emission into total invisibility
— the consequence depends on the property's initial value.) The two namespaces
are opposites, so this is worth memorising:
| ✅ Correct | ❌ Emits nothing | Namespace |
|---|---|---|
| text-p-warning3 | ~~text-p-warning-3~~ | p-* numeric steps take no hyphen |
| bg-p-primary6 | ~~bg-p-primary-6~~ | p-* numeric steps take no hyphen |
| bg-primary-6 | ~~bg-primary6~~ | top-level aliases take a hyphen |
| text-p-info-fg | ~~text-p-infofg~~ | -fg suffixes take a hyphen |
A missed hyphen here has already caused a real incident: a contrast audit silently dropped a failing component from its own report because the selector it grepped for never existed.
AA-safe Badge/Tag variants, and the Brand variants that don't promise it (PIT-8339)
Measured against each arm's own foreground, 6 of Badge's 16 variants and 7 of
Tag's 14 colour × tone arms measure below WCAG AA — <Badge variant="warning">
is 1.68:1 and variant="info" is 2.36:1. Counting an arm as failing when it
fails in either theme gives 8 of 16 and 9 of 14 instead, which is the
criterion scripts/check-contrast.mjs anchors on and pins as its known-bad
reverse anchor. Both are defensible; a bare count next to the other one reads as
a contradiction, so quote the criterion whenever you quote the figure.
Those variants are frozen, because ~13 packages render them. Sixteen
contrast-tuned twins are additive, and they split 9 *AA / 7 *Brand:
<Badge variant="warning" /> {/* 1.68:1 — legacy, unchanged */}
<Badge variant="warningAA" /> {/* 5.04:1 — guaranteed, both themes, as deployed */}
<Badge variant="secondaryBrand" /> {/* tuned for default navy only — no promise, see below */}
<Tag color="info" tone="soft" /> {/* 2.36:1 — legacy, unchanged */}
<Tag color="info" tone="softAA" /> {/* 7.05:1 — guaranteed */}
<Tag color="primary" tone="softBrand" /> {/* tuned for default navy only */}The suffix is the contract. use-instance-color.ts rewrites the
brand ramp (--p-primary through --p-primary6) on every non-default-navy
instance, so any arm built on that ramp cannot honestly promise AA — on a
branded instance its worst measured ratio is 1.02:1 in dark. The nine *AA
Badge arms (destructiveAA, successAA, warningAA, infoAA,
secondaryInvertedAA, destructiveInvertedAA, successInvertedAA,
warningInvertedAA, infoInvertedAA — BADGE_AA_VARIANTS) and the nine
*AA Tag (colour, tone) pairs are built on the error/success/warning/
info foreground tokens, p-text and p-base instead, so they hold
≥ 4.5:1 at rest and on hover, in both themes, as authored and as
deployed — an unconditional promise.
The other seven Badge arms (defaultBrand, primaryBrand, secondaryBrand,
outlineBrand, defaultInvertedBrand, primaryInvertedBrand,
outlineInvertedBrand — BADGE_BRAND_VARIANTS) and five Tag pairs
(primary / secondary in both tones, plus neutral in softBrand only)
resolve through the brand ramp and are named *Brand. They were named *AA
until PIT-8339 — the arms themselves are unchanged, only the claim is
retired, because the ratio they measure is the default navy's and nothing
more.
Call sites don't need to know which family a colour lands in — an internal
resolveTone() maps either family onto whichever arm actually exists for the
colour, in both directions, so tone="softAA" on a primary/secondary/
neutral Tag still renders correctly (it resolves to softBrand); no call
site breaks from the rename.
⚠️ Breaking visual changes in PIT-8349
None of these change a component's API, but they change what an existing call site looks like. Read them before upgrading.
| What changed | Before | After | If you relied on the old look |
|---|---|---|---|
| borderColor / divideColor DEFAULT | Tailwind gray.200 (#e5e7eb) | hsl(var(--border)) | Nothing to do — the old value was off-palette |
| ringColor DEFAULT | rgb(59 130 246 / 0.5) (blue.500/50) | hsl(var(--ring)) = #BEC4DF | A bare ring-2 used for focus is now 1.73:1 on white, under the 3:1 floor of WCAG 2.2 §1.4.11. See the note below |
| Progress track | bg-secondary (224 32% 98%, 1.06:1 on white — effectively invisible) | bg-p-primary5 (231 26% 90% = #DFE1EC) | Pass className="bg-transparent" for a fill-only bar |
| Progress value | Never forwarded to Radix — every bar reported data-state="indeterminate", no aria-valuenow | Forwarded, clamped to 0..max | Stop keying CSS off [data-state=indeterminate] for determinate bars |
| DialogContent width | w-full (edge-to-edge on a phone; size="full" came out narrower than size="sm" at 360px) | w-[calc(100%-2rem)] | Pass className="w-full" to opt back out |
The focus-ring call (open)
--ring is 229 34% 81% — 1.73:1 against white. That is below the 3:1
minimum WCAG 2.2 sets for a non-text focus indicator (§1.4.11), and it
applies both to a bare ring (new, via the DEFAULT above) and to the
focus-visible:ring-ring that most components already used (unchanged by this
release). No react-ui component regressed — every in-library ring is either
explicitly coloured or ring-0 — but the library-wide focus indicator is weak
and a bare-ring consumer is now weaker than before.
Darkening --ring fixes every focus ring in every consumer at once and is a
one-line change; it is also a visible change everywhere, so it is left as a
deliberate platform decision rather than folded into this release.
Known sub-AA neutral pairs (icons and UI only)
These are the library's own semantic combinations. They clear WCAG 1.4.11 (3:1, non-text) but fail 1.4.3 AA for body text — use them for icons, borders and decorative marks, not for prose:
| Combination | Ratio |
|---|---:|
| text-muted-foreground on bg-muted | 4.36:1 |
| text-muted-foreground on bg-accent | 3.52:1 |
| text-muted-foreground on bg-primary/10 | 3.84:1 |
| text-p-text2 on bg-p-primary6 | 4.36:1 |
For body text on those surfaces use text-p-text (12.32–15.28:1).
Icons
The library uses Font Awesome Pro via the FaIcon component. Lucide is not used.
import { FaIcon } from '@pitcher/react-ui'
// Font Awesome class syntax
<FaIcon icon="fa-regular fa-star" />
<FaIcon icon="fa-solid fa-check" />
// Shorthand with type prop
<FaIcon icon="star" type="far" />
<FaIcon icon="check" type="fas" />
<FaIcon icon="spinner" type="fal" className="animate-spin" />
// Sizing
<FaIcon icon="star" type="far" size="sm" />
<FaIcon icon="star" type="far" size="lg" />
<FaIcon icon="star" type="far" size="2x" />Typed icon names
FaIconName is the union of all 4854 icons the shipped Font Awesome CSS can
actually render. It is generated from that CSS (scripts/generate-fa-icon-names.mjs,
guarded by a test that re-derives the list), so it can never drift from the
assets:
import type { FaIconName } from '@pitcher/react-ui'
const STATUS_ICON: Record<Status, FaIconName> = {
ok: 'fa-circle-check',
bad: 'fa-triangle-exclamation',
// typo: 'fa-triangle-exclamaton', ← compile error
}The icon prop itself is NOT checked. It is
FaIconName | (string & {}), and the string arm swallows anything — so
<FaIcon icon="fa-stat" /> {/* compiles. Renders nothing. No error, anywhere. */}The union arm buys autocomplete, not validation. You only get a compile
error when the value is annotated FaIconName — a constant, a
Record<K, FaIconName>, a typed function parameter:
const icon: FaIconName = 'fa-stat' // ❌ compile error
const ICONS = { ok: 'fa-stat' } satisfies Record<string, FaIconName> // ❌ compile error
<FaIcon icon={ICONS.ok} /> // ✅ checked at the constantThe string arm is what keeps the "fa-solid fa-eye" composite form and
dynamically-built names compiling; narrowing the prop itself needs a coordinated
sweep of the ~101 consumer packages first.
Utilities
cn() — Class Name Merging
Combines clsx + tailwind-merge for conflict-free class composition:
import { cn } from '@pitcher/react-ui'
<div className={cn(
'px-4 py-2 rounded',
isActive && 'bg-primary text-primary-foreground',
className // allows consumer overrides
)} />Adding New Components
Use the Shadcn CLI from the react-ui package directory:
cd packages/libs/react-ui
npx shadcn@latest add <component-name>The CLI reads components.json (configured for new-york style, Font Awesome icons, no RSC) and places the component in src/components/. After adding, export it from src/index.ts.
Building & Publishing
# From web/ root
yarn publish:react-ui # Build + publish @latest
yarn publish:react-ui:beta # Build + publish @betaThe library build uses Vite (yarn lib:build inside the package). This is for npm publishing only — not needed during local development since monorepo consumers resolve TypeScript source directly.
The npm package ships src/ for Tailwind class scanning and TypeScript source resolution. All consumers (Vite, Next.js, webpack) compile from source directly.
Example App
An example Pitcher installable app using this library is available at:
packages/libs/react-ui/examples/installable-app/It demonstrates all components with a full UI showcase. To run it locally:
cd packages/libs/react-ui/examples/installable-app
npm install
npm run devPeer Dependencies
react^18.0.0 || ^19.0.0react-dom^18.0.0 || ^19.0.0react-hook-form^7.71.2
License
Proprietary - Pitcher AG
