Install · Styles · Theme · Imports · Live docs ↗
Code-themed, RSC-friendly, ESM-only UI kit for 42 Intra-Next.
Install
The kit is on the public npm registry as @42-central/ui-react. No
credentials are needed:
npm install @42-central/ui-react @ark-ui/react react react-dom tailwindcss
pnpm add @42-central/ui-react @ark-ui/react react react-dom tailwindcss
yarn add @42-central/ui-react @ark-ui/react react react-dom tailwindcss
bun add @42-central/ui-react @ark-ui/react react react-dom tailwindcss
@ark-ui/react, react, react-dom (all React 19) and tailwindcss v4 are
peer dependencies.
Consumer
tsconfig.jsonmust use"moduleResolution": "Bundler"(or"NodeNext").
Using Vite? Add @42-central/vite-plugin-theme too, for flash-free theme
loading (see Vite).
Wire up the styles
The kit ships its Tailwind theme and palettes as plain CSS — there is no plugin to register. Pick one of two patterns in your global stylesheet.
Option A — opinionated (one line). Pulls in Tailwind, the theme, and a
@source glob that scans the built kit:
@import "@42-central/ui-react/styles.css";
Option B — full control (recommended for app code). Import Tailwind and the
theme yourself, and point @source at the installed package so production
builds keep the classes used inside the components:
@import "tailwindcss";
@import "@42-central/ui-react/theme.css";
@source "../node_modules/@42-central/ui-react/dist/**/*.js";
Option A skips the @source declaration, so production builds may miss dynamic
classes used inside the components — stick with Option B unless you have full
ahead-of-time class coverage. For local monorepo development point @source at
the source tree instead — see apps/docs/app/global.css for a working example.
Theme (light/dark/system)
useTheme() persists an explicit "light" | "dark" | "system" choice and keeps
data-theme on <html> in sync — that attribute is the whole contract (the
@custom-variant dark in theme.css above). Avoiding a flash of the wrong
theme needs a script that runs before your app's JS does; how you deliver
that differs by framework.
Next.js (App Router / RSC)
Render ThemeScript from a Server Component, as early as possible (e.g. the
first child of <body>), and mark the root <html> suppressHydrationWarning
(the script sets data-theme before React hydrates it):
// app/layout.tsx
import { ThemeScript } from "@42-central/ui-react/theme-script";
export default function Layout({ children }: LayoutProps<"/">) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeScript />
{children}
</body>
</html>
);
}
"use client";
import { useTheme } from "@42-central/ui-react";
export function ThemeToggle() {
const { resolvedTheme, setMode } = useTheme();
const next = resolvedTheme === "dark" ? "light" : "dark";
return (
<button onClick={() => setMode(next)}>
{/* Which emoji is *visible* is CSS-driven (dark:), not resolvedTheme —
a server can't know the visitor's real preference, so anything
rendered from resolvedTheme is wrong in the raw server-rendered
HTML until React hydrates. resolvedTheme is only used above, for
the click handler's "what's next" logic. */}
<span className="dark:hidden">☀️</span>
<span className="hidden dark:inline">🌙</span>
</button>
);
}
Vite (client-only SPA)
There's no server render step, so a React ThemeScript component can't help —
by the time React could insert a <script>, first paint has already happened.
The script needs to land in index.html itself, genuinely blocking (not
type="module", which Vite treats as deferred). Three ways to get it there,
in order of preference:
1. @42-central/vite-plugin-theme (recommended).
Injects the script into index.html at both vite dev and vite build time,
so you never edit the HTML by hand:
// vite.config.ts
import { defineConfig } from "vite";
import { themeInitPlugin } from "@42-central/vite-plugin-theme";
export default defineConfig({
plugins: [themeInitPlugin()],
});
Install and options are in the plugin README.
2. The static asset, copied into public/. @42-central/ui-react/theme-init.js is
a prebuilt, dependency-free script. A hardcoded <script src="/node_modules/...">
only works in vite dev — Vite's dev server serves node_modules directly,
but a production build doesn't copy or resolve raw node_modules paths, so
that tag would 404 after a real build. Copy the file into your own public/
directory instead (Vite serves public/ verbatim in both dev and prod), e.g.
via a small predev/prebuild script:
// scripts/copy-theme-init.mjs
import { copyFileSync } from "node:fs";
copyFileSync(
import.meta.resolve("@42-central/ui-react/theme-init.js").replace("file://", ""),
"public/theme-init.js",
);
<!-- index.html -->
<script src="/theme-init.js" data-default-color-scheme="dark"></script>
It's configured via data-* attributes on its own tag (no build step needed
once copied):
| Attribute | Matches useTheme() option |
|---|---|
data-storage-key | storageKey |
data-default-color-scheme | defaultColorScheme |
data-force-color-scheme | forceColorScheme |
data-attribute | attribute — comma-separated, e.g. "class,data-theme" |
3. Manual paste, as a last resort (non-Vite bundlers, fully custom setups)
— the literal output of buildThemeInitScript():
<script>
(function () {
try {
var k = "ui-theme",
m = ["light", "dark", "system"];
var s = localStorage.getItem(k),
v = null;
if (s) {
try {
v = JSON.parse(s);
} catch (e) {
v = null;
}
}
if (m.indexOf(v) === -1) v = "system";
var d =
v === "system"
? window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: v;
document.documentElement.setAttribute("data-theme", d);
} catch (e) {}
})();
</script>
Regenerate this block (import { buildThemeInitScript } from "@42-central/ui-react")
whenever you upgrade the package, in case its internals change.
useTheme() works identically after any of these — same hook, no Vite-specific API.
Forcing or defaulting a color scheme
defaultColorScheme overrides the fallback used when nothing is stored yet
("system" unless set). forceColorScheme pins mode/resolvedTheme to a
fixed "light"/"dark" value and turns setMode into a no-op — useful for a
page that must render one scheme regardless of the visitor's preference (e.g.
a themed marketing page or an embed).
There's no provider holding this configuration in one place, so both options
must be passed identically to every useTheme() call site and
<ThemeScript> (or the pasted Vite script) — otherwise the pre-hydration
script and the hook can disagree on first load. This isn't a gap from lacking
a provider: Mantine, which does have one (MantineProvider), documents the
exact same requirement for its own forceColorScheme — it "must be set both
on MantineProvider and ColorSchemeScript," since the script always runs
before any provider exists.
// app/layout.tsx
<ThemeScript forceColorScheme="dark" />
"use client";
const { resolvedTheme, setMode } = useTheme({ forceColorScheme: "dark" });
// resolvedTheme is always "dark"; setMode is a no-op while forced.
Driving a .dark class too
data-theme is the kit's own contract (theme.css's @custom-variant dark), but other CSS you
don't control might key off a literal .dark class instead — Tailwind's default dark strategy,
another component library, or (as in this repo's own docs site) Fumadocs' bundled styles. Pass
attribute (mirroring next-themes' option of the same name) to have @42-central/ui-react drive that too,
instead of hand-rolling your own mirror script/observer:
// app/layout.tsx
<ThemeScript attribute={["class", "data-theme"]} />
"use client";
const { resolvedTheme, setMode } = useTheme({
attribute: ["class", "data-theme"],
});
Like storageKey/defaultColorScheme/forceColorScheme, attribute must be passed identically to
both <ThemeScript> (or the pasted Vite script) and every useTheme() call site, for the same
reason — there's no provider holding this configuration in one place. Passing attribute: "class"
alone (no "data-theme") is supported too, if you genuinely don't need the kit's own contract for a
given page — resolvedTheme reads the .dark class directly in that case.
Disabling the color transition
If any of your CSS transitions a color/background/border property, switching
themes makes it visibly animate through the swap — so transitions are
suppressed by default for one frame around every applied change (clicking
a toggle, the OS preference flipping while in "system" mode, and regaining
focus on a tab where the theme changed elsewhere). Pass keepTransitions
(mirroring the option of the same name in Mantine) to opt back into normal
transition behavior:
const { resolvedTheme, setMode } = useTheme({ keepTransitions: true });
Cycling modes with an updater
setMode also accepts a useState-style updater, resolved once against the current mode —
useful for a single button that cycles through all three values instead of a two-way toggle:
const NEXT = { light: "dark", dark: "system", system: "light" } as const;
const { mode, setMode } = useTheme();
<button onClick={() => setMode((mode) => NEXT[mode])}>{mode}</button>;
Subpath imports
Tree-shake at the component boundary:
import { Button } from "@42-central/ui-react/button";
import { Modal } from "@42-central/ui-react/modal";
import { ThemeScript } from "@42-central/ui-react/theme-script";
import { cn } from "@42-central/ui-react/cn";
The package root (@42-central/ui-react) only re-exports shared utilities and types —
cn, COLORS / Color, SIZES / Size, FormControlProps, useLogger, and
useTheme / ThemeMode / ResolvedTheme. Components live at their own
subpaths so bundlers never pull in the whole kit.