Once UI 2.0 is on npm under the alpha tag. Before the release tour, here is the technical story: why it is a major, what a measured pass over the library found, what moved out of our own products into core, and how to test it.
Once UI 2.0 is on npm as an alpha. It sits on its own dist-tag, so nothing you have installed changes until you ask for it:
npm i @once-ui-system/core@alpha
The stable latest tag keeps resolving the 1.8 line, and no ^1.8.x range in the wild can pick up a prerelease by accident. We are already running the alpha in production apps of our own, and it is holding up. The public release will come with the visual tour. This post is the one before that: what the major actually is, what we found while building it, and what we would like you to break.
Why this is a major, and why it skipped 1.9
1.9.0 existed for a while as a working number. It was never published, and it will not be. By our own release criteria, what accumulated on the branch is a major: props are renamed across a few dozen components, three modules leave the root import for subpaths, one type narrows from string to a real union, and a browser floor appears where there was none.Almost all of it is mechanical. A single-file codemod applies every rename, follows import aliases, scopes each rewrite to the component it belongs to, and is a no-op when run twice. The list of things it cannot do fits in one section of the migration guide. Most apps finish in the first two of its six steps.
Measured, not assumed
The one rule that shaped this release more than any feature: nothing gets claimed without a measurement. When a token layer moves to a new colour space, every token is painted to a canvas in a real browser and compared to the hex it replaced. When 3,000 lines of hand-written CSS become a generator, the compiled output is diffed rule for rule inside every media query. When a fix is "nothing changes visually", the borders are measured in Chromium before and after.That discipline is why 2.0 has a long list of fixes for things nobody reported. They were not visible, because they were not doing anything. Here is what turned up.
The dependencies that were never optional
1.7 made recharts, PrismJS and CompressorJS optional. Except it did not. The lazy await import() with a catch() around it looked right, but a bundler resolves the specifier when it walks the module graph, long before any code runs. The root barrel re-exported the chart, code and media modules, so all three specifiers were in every consumer's graph. An app that rendered no chart, no CodeBlock and no MediaUpload still could not build without all three installed. The fallback never got the chance to run.The fix is not a smarter loader. It is keeping the specifier out of the graph of anyone who does not ask for it:
import { LineChart } from "@once-ui-system/core/data";
import { CodeBlock } from "@once-ui-system/core/code";
import { MediaUpload } from "@once-ui-system/core/media";
Each subpath names its own peer. An app that imports none of them installs none of them and is 13.5 MB lighter. The codemod moves the imports; across our own products that was 53 files.
Icons were the larger version of the same problem. Core rendered 54 icons through react-icons, so every install pulled 85 MB of icon families for about 4 kB of SVG. The data is inlined now, generated from a manifest, with a parity test that renders every icon against its original and asserts identical markup. A fresh install of core goes from 513 MB to 47 MB.
Names that meant two things
Most of the renames resolve one of two situations: one prop name carrying two meanings, or one meaning going by two names. radius meant roundness on every component and corner selection on five of them. variant meant appearance on eleven components and colour scheme on two. fill on Media, Carousel and Swiper did not fill anything: it dropped the intrinsic aspect ratio and handed sizing to the parent, which is a reasonable thing to want and nothing like what the name says.
Component
1.8.x
2.0
Button, IconButton, Input, Textarea, ToggleButton
radius="top-left"
corners="top-left"
Pulse, Tag
variant
scheme
Media, Carousel, Swiper
fill
stretch
Dialog, Modal, DatePicker, DropdownWrapper
isOpen
open
Checkbox, RadioButton, Switch
isChecked
checked
Input, Textarea, Option
hasPrefix, hasSuffix
prefix, suffix
Input, Textarea
height
size
ProgressBar
label (boolean)
showLabel
Feedback, Toast
icon (boolean)
showIcon
SegmentedControl
selected, onToggle
value, onChange
RevealFx
trigger
revealed
The is and has prefixes have a history worth knowing. checked, size and prefix are all attributes React declares on its own DOM types, which is why Once UI avoided them in the first place. Those components now Omit the inherited declaration and declare their own. The cost is that the native attribute can no longer be forwarded: size on an Input is the token scale, not the HTML character-width attribute.
Timing was split three ways. Most durations were already milliseconds, two were seconds, and four speed props are unitless multipliers that are not durations at all. The two outliers move to milliseconds. RevealFx was the sharpest case: its delay was seconds while its own speed, on the next line of the same interface, was milliseconds.
One rename cannot be applied by a script. ColorInput now hands its onChange the value, like every other onChange in the library, instead of a hand-built event. The codemod flags every call site and leaves the handler body to you.
Icon names are a real type
IconName used to collapse to string, so every icon name compiled, typos included, and a wrong one rendered a blank space with a console warning. It is a union now.
Turning that on across our own products found 127 distinct unregistered names over 531 call sites, every one rendering nothing. Six of them were only misspelled (email for mail, sparkles for sparkle), and the codemod corrects those. The rest were product glyphs the registry never had: home, folder, upload, barChart, store. The registry goes from 54 icons to 99. Brand marks stay out, because they are trademarks and every app registers its own.
Registering your own is a declaration merge, and the names come with it:
The registry is structural rather than typed against react-icons, so lucide, heroicons or a hand-written SVG component all satisfy it. Expect tsc to find bugs you already had rather than work you have to do.
Responsive layout in the first paint
LayoutProvider no longer takes custom breakpoints. The five steps are fixed: xs 480, s 768, m 1024, l 1440, and xl above all of them.
This is the change that lets a responsive Flex be a server component. The utility classes carry those widths inside their media queries, and a prebuilt stylesheet cannot honour a width the app picks at runtime, because @media does not read custom properties. Supporting both meant two code paths: CSS classes when your breakpoints matched the defaults, and a 450-line hook that mutated element.style after hydration when they did not. A page on the second path was laid out at desktop widths in the server's HTML and corrected itself once JavaScript arrived.
That hook is deleted. Flex and Grid render on the server whenever every breakpoint value maps to a class, and the responsive classes are in the HTML:
Generating those classes also exposed that a token in a breakpoint prop had never worked. s={{ gap: "4" }} is what the docs teach, and it resolved to nothing: no .s-g-4 class existed, and the client path only handled numbers. Seven more families of breakpoint prop were being written into the DOM as class names with no rule behind them. They exist now. The utility sheet grows from 8.6 to 14.4 kB gzipped, which is the price of the props doing what they say.
Only one app in our fleet passed custom breakpoints, and it was the repo's own dev harness. If your app genuinely needs different widths, open an issue; the honest fix is generating the CSS in your build, which is a real feature and worth doing for a real need.
What generated CSS found
The utility layer was 5,789 lines of hand-written classes with not a single loop. spacing.scss alone was 1,504 lines. The cost was never the typing; it was that a hand-maintained matrix drifts silently. It now lives in a 94-line spec and a 119-line generator, and the checked-in output is verified against a fresh run on every build.
The equivalence was checked selector by selector against the old files, and the diff is the interesting part:
Every spacing family carried all 23 tokens except mx, which was missing 48 and 56. Nobody reading 1,504 lines was going to notice.
.xs-flex-show was visible at every width instead of only the narrowest, because it alone lacked the base rule its three siblings had. The grid variants had the same bug at every breakpoint.
.align-between, .align-around and .align-even set values that are not valid for align-items. The browser dropped the declaration. Fifteen classes, counting breakpoints, that did nothing at all. Removed, and nothing renders differently.
.font-family-display pointed at a token that does not exist anywhere in the token layer.
.radius-none set border-radius: none, which the parser rejects, so it only worked by falling back to the initial value.
--static-space-72 shipped as a token with no utility class and no entry in the type, a step of the scale unreachable from either side.
None of these were reported. A class name that exists either way cannot be caught by a snapshot of class names; only comparing the rules side by side could, which is exactly what a loop does and 1,500 hand-written lines do not.
OKLCH, and the one floor it sets
All 285 scheme tokens are expressed in oklch(). Every value round-trips to the hex it replaced, verified by building the docs and painting each token to a canvas in a real browser: 228 of 228 pixel-identical. What it buys is that the numbers mean something. oklch(0.6743 0.1670 261.54) says two thirds as light as white, moderately saturated, blue. #5A93FC says nothing.
The cost is a browser floor: Chrome 111, Safari 15.4, Firefox 113, all from 2022 and 2023. An older browser drops the declaration rather than approximating it, and there is no fallback. If you support anything older, 2.0 is not for that app yet. Say so in an issue; a fallback layer is possible, it just has no demand behind it.
The scheme generator from Studio's brand page moved into core on the way, without chroma-js. Reducing chroma until a colour fits, instead of clamping RGB channels separately, cut the worst error against the built-in schemes by a third and about five-fold on the lightest steps.
Core without Next.js
next is an optional peer now. SmartLink, Media, Logo, MegaMenu and Kbar render through an adapter layer whose defaults are plain DOM, and the tarball has been verified server-rendering in a React app with no next in node_modules. If you are on Next, keeping 1.8 behaviour is one import path:
import { LayoutProvider } from "@once-ui-system/core/next";
Same component, same props, adapters pre-installed. Skip it and nothing errors, but internal links full-page reload and images bypass next/image. It is the one migration step whose absence is silent, which is why the guide puts it early.
We looked at detecting Next automatically and rejected it on evidence. The bundler half works. React is the blocker: the DOM navigation hook returns a closure while the Next one calls useRouter, so swapping implementations after mount breaks the rules of hooks, and a browser bundle has no synchronous way to resolve an optional module before the first render.
Extracted from the products
The new components in 2.0 were not designed on a whiteboard. Each is something two or three of our own apps had grown a private copy of:
NavItem, NavGroup and selectNavHref, the rows a product sidebar is made of. Aveiro's sidebar was 566 lines, the docs' 456, Frametic's 52, and all three had converged on the same grammar. selectNavHref is the part worth taking: pathname === href misses a nested route and startsWith lights the parent up alongside its child, and the correct answer existed in exactly one repo.
Scrubber, a playhead over time, extracted from Scenetic's editor. With no tracks it is a seek bar; with tracks it is a timeline with selectable, movable, trimmable blocks. Editing is offered, not applied: it reports times and the app decides.
Setting, SettingGroup and SettingAxes, the settings row Aveiro, Frametic and Scenetic each had a version of. This one takes the control as children, so it composes with controls that do not exist yet.
StylePanel is composable and can be host-owned. It had been forked twice, in Magic and in Studio, and both forks diverged on the same three axes, so those are the ones it opens up.
Effect, one slot for the interchangeable ambient layers, so a template can expose its aesthetic as a single setting.
Card takes selected, Logo takes per-theme sources, ThemeSwitcher takes collapsed, and Textarea grows with its content by default.
The full release post will show these properly. For now they are in the docs and the AI harness, and ai/layouts.md has the app shell recipe most of them belong in.
What we hit testing it ourselves
We scaffolded a new product on the alpha this week. Moving the template produced 50 type errors, all in the template's own code, in five groups: the three subpath moves, the prop renames, the millisecond timings, icon fields typed as string in content files, and an icon library typed against the full registry. All of it was mechanical, and all of it is now in the codemod or the migration guide.
Two things were not mechanical and are worth knowing. Content in plain .js files cannot carry the IconName type without a JSDoc annotation, so content should be TypeScript. And a bare number on width or height is rem, so a width={10} icon tile is 160 pixels wide; the token form width="40" is what the design intends. One of our own harness examples had the mistake, and it was copied verbatim, which is what examples are for.
Known and deliberate
Three things in the alpha are not bugs, and reporting them will get a polite pointer here:
The published package is bundler-only. Next.js, Vite and friends load it; plain Node require does not. That predates this release, and the module format is a 2.0 decision that has not been made yet.
@once-ui-system/foundations exists in the repo and is not published. Core inlines its tokens and styles at build time, so you install nothing new and every CSS import keeps working.
Breakpoints are fixed. See above.
How to test it
Commit first. Then:
Install the alpha, and expect nothing to build yet. tsc is the checklist for the rest.
Run the codemod, dry first, and read what it reports. It does not read .ts files, and it should run once, on a 1.8 tree.
Install the peers only for the subpaths you actually import.
Fix what the codemod flagged by hand: ColorInput handlers, computed timings, Textarea heights.
Change the LayoutProvider import if you are on Next.
The API in the alpha is what we intend to ship, but the point of an alpha is to find out where that is wrong. What we want to hear: a rename the codemod missed, a component that behaved differently after the CSS generation, a token that looks off in OKLCH on a screen we do not have, a breakpoint case the fixed scale cannot express. Open an issue on the repo with the version, and the changelog there is the full record if you want the reasoning behind any single change.