Pho Design System

Getting started

Everything a product app needs to adopt Pho: install the package, wire the Tailwind theme, self-host the fonts, opt into dark mode, and connect your router. Five minutes end to end.

Install

pnpm add @photon-ai/pho-ui @tabler/icons-react

react / react-dom (18 or 19) and @tabler/icons-react are peer dependencies; Base UI, Motion, and the class utilities come with the package.

Wire the theme

Pho is a Tailwind v4 theme plus components. In your app’s CSS entry, import the tokens after Tailwind and point a @source at the shipped components so their utility classes are generated:

@import "tailwindcss";
@import "@photon-ai/pho-ui/styles";
@source "../node_modules/@photon-ai/pho-ui/dist";

This gives you every pho-* semantic utility (bg-pho-page, text-pho-secondary, ring-pho-brand, …), the accent palette (--color-pho-yellow--color-pho-navy), the squircle and transition-inherit-all utilities, and the closed type system.

Two things are deliberately strict:

Fonts

The brand fonts are served from Photon’s asset CDN (assets.photon.codes/pho-ui/v1/, immutable caching, CORS enabled) — one import and they work, no files to copy:

@import "@photon-ai/pho-ui/styles/fonts";

To cut the first-paint swap, preload the primary face in your <head> (crossorigin is required for fonts):

<link
  rel="preload"
  as="font"
  type="font/woff2"
  crossorigin
  href="https://assets.photon.codes/pho-ui/v1/PolySansVF.woff2"
/>

Two escape hatches: if your app sets a font-src CSP, allow https://assets.photon.codes; if you must self-host (air-gapped), skip the import and declare the same faces against your own paths — the tokens only reference the family names. PolySans is the voice (body 400, UI/headings/ hero slogans 550), Azeret Mono covers code and small labels, Instrument Serif is the italic editorial accent.

Dark mode

Every color token resolves through CSS light-dark(), switched by color-scheme. The stylesheet sets light on :root and dark on [data-mode="dark"] and .dark — flip either on <html> (or any subtree) and the whole system follows:

<html data-mode="dark">
  <!-- everything inside renders with the dark values -->
</html>

Prefer the packaged segment — Device / Light / Dark. It flips data-mode, follows the OS when Device is selected, persists under pho-mode, and keeps multiple instances in sync:

import { ThemeToggle } from "@photon-ai/pho-ui/components/theme-toggle";

<ThemeToggle />;

Add a blocking script in <head> so the first paint matches storage:

<script>
  const mode = localStorage.getItem("pho-mode");
  const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
  if (mode === "dark" || ((mode === "device" || mode == null) && prefersDark))
    document.documentElement.dataset.mode = "dark";
</script>

Using next-themes or a class-based switcher? Add a one-line bridge so your class also flips the scheme:

.dark-mode {
  color-scheme: dark;
}

Connect your router

ButtonLink, EffectButtonLink, and other navigation components render through LinkProvider, so client-side routing works without Pho depending on any router. Mount it once at the app root with your router’s link:

import { LinkProvider } from "@photon-ai/pho-ui/utils";
import { Link } from "react-router"; // or TanStack Router, Next.js, …

export function App({ children }) {
  return (
    <LinkProvider
      component={({ href, ...props }) => <Link to={href} {...props} />}
    >
      {children}
    </LinkProvider>
  );
}

Without a provider, links fall back to a plain <a> — fine for MPAs and prototypes.

Import components

Every component ships as its own entry for tree-shaking:

import { Button, ButtonLink } from "@photon-ai/pho-ui/components/button";
import { Input } from "@photon-ai/pho-ui/components/input";
import { Dialog } from "@photon-ai/pho-ui/components/dialog";
import { GoogleIcon, PhotonMark } from "@photon-ai/pho-ui/icons";

Prefer these subpath imports over the root barrel in app code — bundlers keep only what you use. Browse the full catalog under Components.

The look, in one paragraph

Pho is monochrome, straight from photon.codes: the app canvas is bg-pho-page, content sits on bg-pho-primary cards with a border-pho-secondary hairline and no shadow (elevation belongs to floating layers only), buttons are pills with font-medium labels, the primary action is the black/white contrast fill (bg-pho-brand-solid + text-pho-on-brand), and ink is one ladder for copy and icons — text-pho-primary (100%), text-pho-secondary (75%, UI chrome), text-pho-description (55%, supporting prose). Color appears only as functional states and the categorical accent set — see Colors.