osp-ui-kit
v0.2.7
Published
Reusable React component library and design system for St. Peter Life Plan's Online Service Portal, built on Chakra UI v3.
Readme
osp-ui-kit
React component library and design system for St. Peter Life Plan's Online Service Portal, built on Chakra UI v3.
It ships six brand palettes, a full set of base primitives, and roughly 300 named exports — a data table, an app shell, form controls, overlays, charts and a PDF viewer among them. The visual language follows BootstrapMade NiceAdmin: navy ink on a pale blue canvas, 5px corners, and a soft navy card glow instead of hard borders.
Install
npm install osp-ui-kitTwo entry points are kept out of the main barrel because each pulls a dependency most consumers never need:
import { AppLayout, DataTable, BaseButton } from "osp-ui-kit";
import { AreaChart, LineChart } from "osp-ui-kit/charts"; // needs `recharts`
import { PdfViewer } from "osp-ui-kit/pdf-viewer"; // needs `react-pdf`Setup
Wrap your app in StPeterProvider. It builds the Chakra system, loads the fonts, and installs every semantic token the components resolve against — nothing in the kit renders correctly without it.
import { StPeterProvider } from "osp-ui-kit";
export default function App({ children }) {
return <StPeterProvider theme="niceadmin">{children}</StPeterProvider>;
}theme accepts neutral (the default), green, pink, blue, orange and niceadmin.
Dark mode
Every semantic colour in the kit carries both a light and a dark value, and the swap happens in CSS. Components name tokens; they never ask which mode is active. That means dark mode is one provider away, and a component you write on top of the kit gets it for free as long as it uses tokens rather than literal colours.
Each palette keeps its own hue in the dark: a green-theme app becomes a deep green-black, niceadmin becomes the navy its #012970 headings hint at. There is no neutral-grey theme and no pure-black surface anywhere.
Enabling it
Add ColorModeProvider above StPeterProvider:
import { ColorModeProvider, StPeterProvider } from "osp-ui-kit";
export default function App({ children }) {
return (
<ColorModeProvider>
<StPeterProvider theme="niceadmin">{children}</StPeterProvider>
</ColorModeProvider>
);
}That is the whole integration. ColorModeProvider writes class="dark" onto <html>, and the token layer does the rest.
Reading and setting the mode
import { useColorMode } from "osp-ui-kit";
function Example() {
const { colorMode, colorModePreference, setColorMode, toggleColorMode } = useColorMode();
// colorMode -> "light" | "dark" (what is rendering now)
// colorModePreference -> "light" | "dark" | "system" (what the user chose)
}Use colorMode to branch on things that are not colours — an icon, a chart type, an image. For colours, name a token and let CSS resolve it; a JS branch cannot run before hydration, so the first paint would get the wrong one.
The toggle button
ColorModeToggle is a sun/moon icon button styled like the kit's other header actions. Drop it wherever you like:
import { ColorModeToggle } from "osp-ui-kit";
<ColorModeToggle />;AppLayout and AppHeader can also place it for you:
<AppLayout navItems={navItems} showColorModeToggle>
{children}
</AppLayout>It is off by default, so an app that has not mounted ColorModeProvider never renders a control that appears to do nothing.
Three states
light, dark and system are all supported. system follows prefers-color-scheme and keeps following it — flipping the OS appearance while the tab is open updates the page live.
light is the default so that adding the provider to an existing app changes nothing until someone asks for dark. To follow the OS out of the box:
<ColorModeProvider defaultTheme="system">{children}</ColorModeProvider>Persistence
The user's explicit choice is stored in localStorage under the key color-mode. Change it with storageKey, which matters when several apps share an origin and should not fight over one value:
<ColorModeProvider storageKey="osp-portal-color-mode">{children}</ColorModeProvider>To pin one mode for a route — a print view, a marketing page — pass forcedTheme="light". It overrides both the stored value and the OS, and useColorMode reports the forced value.
ColorModeProvider accepts every next-themes ThemeProvider prop, so nonce (for a strict CSP) and the rest pass straight through.
Next.js App Router (SSR)
The server cannot read localStorage, so it always renders the default mode. next-themes closes the gap with a small blocking script that sets the class before first paint — no flash of the wrong theme. React needs to be told the class it finds is expected, which is what suppressHydrationWarning does:
// app/layout.tsx
import { ColorModeProvider, StPeterProvider } from "osp-ui-kit";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ColorModeProvider>
<StPeterProvider theme="niceadmin">{children}</StPeterProvider>
</ColorModeProvider>
</body>
</html>
);
}Three notes for App Router specifically:
suppressHydrationWarninggoes on<html>and nowhere else. It suppresses the warning for that element's own attributes, not for your whole tree.- Both providers are client components. Mounting them in
app/layout.tsxdoes not make your pages client components — children stay server components. - Anything that renders differently per mode must not render its mode-dependent output during SSR.
ColorModeTogglehandles this itself (it renders a same-size skeleton until mounted); do the same in your own components, or use a token so the branch happens in CSS instead of in JS.
The resolved mode is also mirrored onto the root's color-scheme, so native scrollbars, form controls and <select> popups go dark with the page.
Writing components that work in both modes
Use the semantic tokens. Never a raw Chakra palette step (gray.200, red.500) and never a literal hex — those are fixed values with no dark counterpart.
| Instead of | Use |
| --- | --- |
| gray.50, gray.100 | canvas (inset panel), primaryTint (hover fill, icon tile) |
| gray.200 (border, track) | hairline, or hairlineSubtle for rules that repeat |
| gray.300, gray.400 (icon, disabled) | muted |
| gray.500, gray.600 (secondary text) | mutedStrong |
| red.* / orange.* / green.* | danger / warning / success and their *Tint / *Fg |
| white on a primary fill | primaryForeground |
| white behind a dialog, menu or popover | surfaceRaised |
| Chakra's bg, bg.panel, fg, fg.muted | canvas, surfaceRaised, foreground, mutedStrong |
Three that are not one-to-one:
- Text on a tint takes the
*Fgtoken, not the accent. In light,warningonwarningTintis 1.68:1. The accent is for the dot or the glyph. primaryis not a text colour in light mode — 1.71:1 on the blue palette. For brand-coloured text useinfoFg.- A hue the kit does not carry (a distinct purple, an orange between red and amber) should borrow Chakra's own semantic pairs —
purple.subtle/purple.fg— which do have dark variants. Never a raw step.
Inline styles, gradients and SVG attributes cannot take Chakra props. Read the custom property directly, kebab-cased, and use color-mix for translucency:
stroke="var(--chakra-colors-hairline)"
style={{ background: "color-mix(in srgb, var(--chakra-colors-canvas) 92%, transparent)" }}In props, Chakra's own alpha shorthand does the same thing: bg="surface/80".
Accessibility
Contrast in this kit is measured, not eyeballed. Dark mode was audited across all six palettes and all three planes (canvas, surface, surfaceRaised) — 318 text and UI pairs, every one at or above WCAG AA. Worst cases: heading 13.64:1, body foreground 10.09:1, mutedStrong 8.31:1, muted 5.36:1, primary as text 5.27:1, primaryForeground on primary 6.26:1, and every *Fg on its tint at 5.38:1 or better.
Reuse the existing pairings rather than inventing new ones.
Commands
npm run build # tsc -b, then vite build -c vite.lib.config.ts
npm run lint # oxlint
npm run test # Playwright Component Testing
npm run test:update-snapshots # regenerate visual snapshots after an intentional restyle
npm run storybook # development, documentation and design review
npm run build-storybook
npm run audit:contrast # WCAG audit: 6 palettes x 2 modes x 3 planes
npm run audit:light-tokens:snapshot # record light-mode token CSS before a change
npm run audit:light-tokens # ...and prove it did not move afterThe two audits read the palettes from source, so they cannot drift from what ships. Run them after touching any colour token.
Storybook is the development and design-review environment; there is no dev server and no src/App.tsx. The Theme and Color mode toolbar controls compose, so any story can be reviewed in all six palettes and all three modes. Getting Started/NiceAdmin Showcase is the reference page, and its Light and Dark story puts both modes side by side.
Type-aware lint rules are not enabled; turning them on needs oxlint-tsgolint and "typeAware": true in .oxlintrc.json.
