Vercel's UI SDK went into public beta at 01:12 this morning, and by breakfast I had it installed, counted, and running. Four seconds to install. Zero dependencies. Ninety-one behaviors, not the "50+" on the tin. And the first one I tried, a popover, opened, took focus, closed on Escape, and handed focus back, exactly as the docs promised.
That last part is the one I did not expect. First-day betas usually have a gap between the docs and the download. This one, on the one behavior I ran, did not.
Install: 4 seconds, nothing attached
added 1 package in 4s One package, no dependencies of its own, 7.5 MB on disk. The distribution folder holds 388 JavaScript files totalling 2.1 MB, plus docs and a skills folder that ship inside the package itself. The main entry point is a 7 KB barrel file; the real code lives in the subfolders. License is Apache 2.0, version 0.1.0-beta.1.
The docs deserve a line of their own. The package carries an 8,027-line llms.txt plus a full core.md guide, and a skills folder with reference pages for building components, patterns, styles, and testing. The pitch is that agents assemble interfaces from these behaviors, and the docs are packaged to be read by the thing doing the assembling. Whether that pitch lands is a question for a longer test. The packaging itself is deliberate.
The count: 91 behaviors, and what that number means
The announcement says 50+ interaction behaviors. I counted every create* function exported across all 70 subpath entry points, plus the main entry. The total is 91 distinct functions, and every single subpath loaded without an error.
But 91 needs a footnote, because two different kinds of thing are hiding in it. The main entry exports 36 low-level behaviors: createPopup, createSelection, createTypeahead, createRovingFocus, createVirtualizer, createUndoStack, and 30 more of that kind. The other 55 are component controllers, one per widget, with names like createTabsController, createCalendarController, and createDatePickerController. Both are real, both are usable, but they are not the same kind of building block. If you were hoping for 91 low-level behaviors, the honest count is 36. If you count controllers, 91 is fair, and the "50+" claim holds either way you slice it.
| What I measured | Result |
|---|---|
| Install time | 4.3 s |
| Dependencies | 0 |
| Size on disk | 7.5 MB (6.6 MB dist, 388 files) |
| Subpath entry points | 70 |
create* functions, total | 91 |
| Low-level behaviors (main entry) | 36 |
| Component controllers | 55 |
| One popover, bundled + minified | 98 KB (36 KB gzipped) |
| Whole main entry, bundled + minified | 190 KB (71 KB gzipped) |
The run: one popover, start to finish
I built the docs' own example, an invite popover, from the "A Popup of Your Own" page: popupState for state, createPopup with the popover preset for behavior, and the slot builders for the trigger, the content, and the close button. Then I drove it in a simulated DOM: click the trigger, press Escape, open it again, close it with the Done button.
mounted in 60 ms 1. initial state.open = false | mounted = false trigger aria-expanded = false content present in DOM = false | hidden attr = true 2. after clicking Invite: state.open = true | mounted = true trigger aria-expanded = true content data-side = top activeElement is inside content = true last change: {"key":"open","value":true,"reason":"trigger-press","source":"mouse","label":"Invite"} 3. after Escape: state.open = false | mounted = false focus returned to trigger = true last change: {"key":"open","value":false,"reason":"escape-key","source":"keyboard","label":"Invite"} 4. reopened: state.open = true after clicking Done: state.open = false | focus back on trigger = true total changes recorded: 4 unmounted cleanly. total run time: 497 ms Every step did what the docs said it would. The trigger carries aria-haspopup="dialog" and flips aria-expanded. The content gets role="dialog", aria-labelledby pointing at the trigger, and popover="manual", and the positioning engine computed real placement values, including which side of the trigger the popup landed on. The change log records not just what happened but why and how: trigger-press from the mouse, escape-key from the keyboard. Focus moved into the popup on open and came back to the trigger on close, both times, which is the part of popup behavior that usually breaks first.
This is what the rendered markup looked like after the run, trigger first:
<button data-slot="invite-trigger" id="mount-1-trigger" aria-haspopup="dialog" aria-expanded="false" type="button" data-closed="">Invite</button> <div data-slot="invite-content" role="dialog" id="mount-1-content" tabindex="-1" aria-labelledby="mount-1-trigger" data-presentation="popover" popover="manual" style="position: fixed; inset: -4px auto auto 8px; margin: 0px; --available-height: 0px; --available-width: 0px; --anchor-width: 200px; --anchor-height: 40px; --transform-origin: 92px 0px; --arrow-offset: 6px; z-index: 2147483000;" data-side="top" data-align="center" data-closed="" hidden=""> The styling hooks are all there as data attributes and CSS variables: side, alignment, anchor size, transform origin, arrow offset. Nothing is styled for you; the parts arrive unstyled and you bring your own CSS. That is the design, not an omission, and the docs say so plainly.
One more thing the run confirmed: the same createPopup call changes character entirely when you swap the preset. The docs list four, popover, dialog, tooltip, and hover card, each choosing the content's role, how it opens and closes, and whether it is modal. I only ran the popover preset, so the other three are untested by me, but the mechanism is a single option, not a different behavior.
The catch: one popup costs 98 KB
That is the number that made me pause. Bundling the single popover behavior with a minifier came to 98 KB, 36 KB gzipped. The whole main entry, all 36 behaviors, is 190 KB minified. So one popup pulls in about half the library, because the shared foundation, positioning, layers, focus handling, comes along with it.
Is 36 KB gzipped too much for a popup? Depends on the page. On a dashboard that already ships a component library, it is in the same league as what you are already paying. On a landing page where the popup is the only interactive thing, it is a lot. What I can say from the count is that the tree-shaking works, one behavior is half the library rather than all of it, but the floor is high because the foundation is heavy. That is the tradeoff of the design: the behaviors are genuinely shared, so the first one pays for the plumbing.
What I did not test
This ran in jsdom, a simulated page, not a real one. State, attributes, focus, and keyboard handling all work there, and the SDK's own testing kit documents jsdom as a valid way to drive a primitive. Paint, layout, and real pointer behavior are untested by this run. I ran one behavior out of 91, and one preset out of four. And this is beta.1, released hours ago: the count, the sizes, and the behavior are all a snapshot of this morning, and any of them can move.
Still, the first impression is unusually solid for a day-one beta. The install is clean, the "50+" claim undersells the count, the docs match the download, and the one behavior I ran behaved itself completely. The thing to watch is the bundle floor: 36 KB gzipped for a single popup is the price of admission, and it is worth knowing before you build on it.
P.S. Everything I write stays free — that part doesn't change. But if this one was worth something to you, you can buy me a coffee ☕ (one-time or monthly, both work). It covers the API credits and hosting behind these experiments — and honestly, it's also the nudge that gets the next one started on the evenings I'd otherwise skip it. — Salam




