Rebuilding Catalina as a React interface
How a React library reproduces macOS Catalina's look and its window logic in a browser: interaction from Base UI, appearance as CSS tokens tagged by the evidence behind them, window state carried by the cascade, and a repository where concurrent contributors rarely need the same file.

On this page
A Catalina push button looks like one of the easiest things on a Mac to copy. It amounts to a few gradients, a hairline and a shadow, so with a screenshot, a colour picker and an afternoon you can put a blue Go button in a browser that sits almost exactly on top of the original. What you have copied is one state of that button: in the front window, in the light appearance, with the blue accent, with nobody pressing it. The blue also means something: it marks the button the Return key will press in the window the keyboard is talking to, so when you click on another window the real button gives up its colour. Switch the Mac to dark and every layer of it changes, and with the graphite accent its glyphs invert as well. The button behaves more like a function of its surroundings than like a picture, and a screenshot records the output for a single set of inputs.
Macalicious, which I developed with coding agents, tries to reproduce the function. It rebuilds the interface of macOS 10.15 Catalina as React components, in both the Aqua and Dark Aqua appearances, and from the outside it looks like any other component library:
import { PushButton } from "macalicious/push-button";
import "macalicious/styles.css";
<PushButton>Cancel</PushButton>
<PushButton variant="primary">Go</PushButton>
What runs is HTML, CSS and JavaScript. It is not macOS, it does not emulate an operating system, it has no connection with Apple, and it ships no Apple fonts, icons or wallpapers; on a Mac the system font stack resolves to San Francisco, and elsewhere an optional stylesheet falls back to Inter with San Francisco’s vertical metrics. The question that raises is how much of a desktop’s logic can be carried over honestly with only what a browser provides, and most of the answer came down to deciding which layer should own each part of it.
Behaviour from Base UI, appearance from CSS
Behaviour, focus management and accessibility come from Base UI, whose React primitives have no styles at all. That is exactly the split this library needs, since it takes on all of the visual work and none of the interaction work. It is also what keeps the project from pretending. A Macalicious control is a web control: it accepts the props of the Base UI primitive or HTML element it is built on and passes the rest through, so aria-* attributes, refs and event handlers do what anyone writing React would expect. The keyboard is where the two layers meet, with Base UI managing where focus goes and the ring that shows it belonging to the CSS. Each component has behaviour tests, and every example gets baseline render, server-render and axe-core accessibility tests.
Everything visual is plain CSS, with no Tailwind and no CSS-in-JS runtime. Catalina’s controls are a few gradients, hairlines and shadows per state, which CSS can express directly, at no cost at runtime, in a form you can read next to a measurement taken from a screenshot.
Saying what was measured
The reference is the 512 Pixels Aqua Screenshot Library. The screenshots are third-party images, so they stay out of the repository, which holds a manifest of 294 of them, with source URL, dimensions and SHA-256, and a script that downloads and verifies each one. From them come the design tokens, CSS custom properties named after AppKit’s semantic colours, such as --mac-color-label and --mac-color-window-background. A token holds the value that reproduces the screenshot in a browser, which isn’t always the number AppKit would state. Translucent layers that overlap are stored as the alphas that composite to the measured row of pixels, and a control corner with a nominal 4-point radius comes out as 3.5px.
Every value also carries a tag saying what stands behind it: measured, computed from measurements, catalog or provisional. I think this is the most useful habit in the project, because the evidence runs out in very specific places. For the eight Catalina accents, the bezel and control fills are tables of measured values, since the accents follow no single rule: orange is nearly flat in dark, and graphite is a cool grey that inverts its glyphs. The emphasised selection and the focus ring, on the other hand, are measured for blue only and derived for the other seven, because no screenshot shows those accents on a list or a focused control. Any other colour you supply gets the same tokens derived from it with CSS relative colour syntax, fitted to blue, so a custom accent works but sits further again from anything anyone measured. A blanket claim of pixel perfection would hide all of that. With the tags you can ask how far to trust a particular value and find the answer next to it, and the documentation carries fidelity notes for each component.
A harness holds the comparison to account. It renders an example in a real browser at 1x and 2x and compares it with a crop of a reference screenshot, producing a diff image, a mismatch score and a mean colour difference, each with a required budget. For a state that no screenshot shows, it checks the pixels the comparison expects instead.
Letting the cascade carry state
Accent, appearance and window state have something in common: each is decided far above the control, and a control shouldn’t have to run any code to find out about it. Window state shows this best. AppKit distinguishes three states. The key window receives keyboard input, the main window is the document the person is working on, which is not the key window while a panel has the keyboard, and every other window is inactive. Different parts of the interface ask different questions about this. Controls ask only whether their window is key, which is the question the blue button was answering, the frame asks only whether it is inactive, and the traffic-light buttons distinguish all three.
The natural React answer is a context that every control reads, which would re-render every control in a window each time focus moves between windows, and on a desktop focus moves between windows all the time. Macalicious sets data-window-state="key|main|inactive" on the window element instead, and each component’s stylesheet reads it with an ancestor selector. Components need no context, state or listeners for their look, which is the main reason they are cheap. An absent attribute means key, so a control dropped onto a plain page looks active, and the same attribute on <html> puts the whole page in the background when the browser window loses focus. The price is that CSS cannot select the nearest ancestor with an attribute, so a state that isn’t key wins wherever it sits in the tree. That matches the way an application behaves in the background, and it is the reason windows don’t nest.
Appearance travels the same way, through light-dark(). Each token that differs between the two appearances is written once, as light-dark(<aqua>, <dark aqua>), and setting data-appearance changes color-scheme and so switches every token on the page or in any subtree. The alternative, a block of dark overrides plus a copy of it inside a prefers-color-scheme query, states each dark value twice and makes nested appearances fragile. In exchange the browser baseline is Chrome 123, Safari 17.5 and Firefox 128, and build tools have to be told not to down-level the function. light-dark() accepts only colours, so the few other values that differ, such as a material’s blur radius and saturation, interpolate on a number kept at 0 in Aqua and 1 in Dark Aqua.
Accents hide a trap worth knowing about if you write tokens like these. A custom property that contains var() is resolved on the element that declares it and inherited as a finished value. The bezel gradients combine accent tokens, so declaring them on :root alone would freeze the page’s accent into every subtree, and a subtree with its own data-accent would keep the page’s gradient. They live in a rule that matches [data-accent], which recomputes them wherever the accent changes.
Geometry takes the same approach of making the native model the CSS model. A control’s CSS box is its AppKit alignment rectangle, the rectangle AppKit lays out by, which leaves out ornament such as shadows. The bezel is drawn inside it on a pseudo-element, and one CSS pixel is one AppKit point. The spacing in Apple’s Human Interface Guidelines then applies as plain CSS gaps, 12px between controls and 20px from a window’s edge, and shadows never affect layout.
A dozen contributors and no shared file
The repository had a requirement of its own. Its architecture was written for about a dozen contributors building on it at once, and on a project developed with coding agents, any file all of them have to edit is a place where their work collides. So everything about a component lives in its folder: code, styles, tests, docs, examples and visual comparisons. The docs site, test runner, build, registry and visual harness discover those folders by globbing, which means there is no index file, route table, story list or registry manifest to edit, and the root entry point is generated at build time.
Tokens were the awkward case. Component CSS may not write a colour or test the appearance, so every value that differs between Aqua and Dark Aqua has to be a token, and if all of them lived in the shared token files, those files would become the one place every author edits at once, full of values only one control uses. So tokens have two homes. The shared directory holds Catalina’s vocabulary and anything two components share, and has a single owner who takes requests. A component’s own values live in a <name>.tokens.css file in its folder, named --mac-<name>-*, and a convention check holds that file to declarations of that prefix on :root, [data-appearance] or [data-accent]. That keeps the rule that appearance comes from tokens checkable by a script, because the only file allowed to say light-dark() cannot style an element.
Leaving the repository
A folder is also a convenient unit to hand to someone else. The library is one package with one entry point per component, and the same source feeds a copy-in command in the style of shadcn/ui, whose registry is generated from the tree: a component’s files are its folder, and its dependencies are read from its import statements. Copying suits teams who restyle everything, but this library’s whole value is its exact styling, so most people are better served by a versioned package they can update. The one packaging decision I would copy into any component library is that the published JavaScript never imports CSS, because JavaScript that imports CSS breaks in Node, in server components and in some bundlers, while a stylesheet the application imports works everywhere.
Documentation comes out of the same tree as plain Markdown, one file per component and guide with an llms.txt index, so a coding agent using the library reads the props of the version it has. The agent guide also tells it not to assume a Catalina control exists as a component just because macOS has one, which is exactly what a well-read model would assume.
The package hasn’t gone out to npm yet, and some token values are still tagged provisional, which is the tag doing its job: whoever finds a better screenshot knows which numbers to replace.
Chris Chabot, November 2026