Changelog
vNext (2026--)
Fixed
A secondary line inside a tinted or filled surface is the same ink, one step smaller.
Pill's description wascurrentColorat 72% and a selectedListrow's was--neba-muted-fg, and neither could hold 4.5:1 — the description on asolidpill read 3.2:1 and the row's 3.1:1. The reason is the same in both: the ink on those beds was already solved to the minimum,--n-on-solidon--n-fillbeing 4.6:1 at full strength, so there is nothing to take away. Size and weight carry the step now, which they do on every variant and need no number. A row that is not selected keeps the neutral grey, because on the bare sheet that is what quiet means.A
Chip's count plate carries its own bed instead of another wash on top of the chip's.--n-on-tintis solved for one wash, and the plate was--n-soft-presspainted over whatever wash the chip already had: a count on atextchip read 4.2:1 and on a selected one 3.5:1. It is a fill now on every variant that is not itself filled, which holds 4.6:1 or better wherever the chip is sitting; a filled chip keeps the hole punched in its own fill.Badge,AvatarandAppLogotake the idea the rest of the way, because they are marks a caller drops inside other things and cannot know what: theirtextplate is the wash laid on the page's own surface rather than on the caller's, which makes it opaque and leaves it no bed to depend on. It had none to spare — atextBadge read 3.9:1 on a selected row and 1.45:1 on a filled surface, which is not faint, it is gone. It reads 5.5:1 in light and 7.6:1 in dark now, on every bed, and unchanged on the page: there the opaque colour is what the wash resolved to.Text on a surface tinted with its own colour has an ink of its own.
panelandsoftare both washes of the family'saccent, so writing the label in that accent put a colour on a pale copy of itself — the two move together, and past a wash of about 9% no adjustment to either one can reach 4.5:1.soft-pressis 25%. AnonToggle, atextChip, a highlighted menu row and anoutlineButton under the pointer were all between 3.4:1 and 4.5:1, in both themes; this one was never a dark-mode fault.--neba-{color}-on-tintis the ink for those beds, the wayon-solidis the ink for a fill:accentpulled 28% toward--neba-fg, so it turns toward near-black on a white page and near-white on a dark one without a second value to keep in step. Every family now clears 4.5:1 on every step of both ladders, worst case 4.8:1 in light and 5.6:1 in dark.accentis untouched, so a TextLink, a Statistic's delta and an Alert's title read exactly as they did, and a project that overrides the five hand-picked values of a family gets the new ink derived from them for free.Dark mode's panel ladder is 5/7/9%, down from 7/10/13%. The ladder is opacity, which is why it is free on a white page — white over white is white — and expensive on a near-black one, where every step multiplies the bed's luminance and nested sheets compound. At the old numbers a Card was 2.6x the page and a table header inside that Card was 7.5x, which squeezed the whole ink hierarchy into the top of its range:
--neba-fgitself fell from 16:1 to 9:1 just by being inside two sheets. The light theme's own ladder moves a sheet's lightness by nothing at all, and it is the hairline and the plate edge that say where a Card begins there, so the dark sheet keeps more separation than its light counterpart even after the change.Dark mode's secondary ink, disabled ink and hairline are measured on that raised sheet, not the bare one. All three were picked against
--neba-surface, which is the one bed almost nothing is drawn on. So a DataTable's column names came out at 3.3:1, a SegmentedButton's unchosen labels at 3.9:1, a Combobox's placeholder at 4.5:1, and a disabled Select option or segment at 1.8:1 — a fifth of what its own label reads at.--neba-muted-fgnow reads 5.05:1 on a table header inside a Card and 4.34:1 with a page shell around that, against the 4.33-4.64:1 the light theme reads everywhere — parity at every depth the library composes.--neba-disabled-fglands inside the 2.0-2.4:1 light reads at, still clearly unavailable and no longer gone. Nothing in the light theme moved.A hairline in dark mode is lighter than the sheet it is drawn on.
--neba-borderwas 0.11 above the bare surface in perceptual lightness but 0.02 to 0.05 below a raised one, so on anything that is not the bare page the edge that is supposed to catch the light was a black scratch. A TreeView's rails and elbows were the clearest case, at 1.03:1 against the sheet behind them; a chart's grid and axis, a Panes divider, a Slider's ticks, an Anchor's rail, a Timeline's connectors and a DataList's rules are derived from the same token and were all doing it. The dark value now sits above the whole panel ladder. It is more present on a plain page than the light hairline is, which is the trade — one value cannot be a whisper across a ladder that moves.A heading a component renders keeps the sheet's type, not the article's. The same specificity problem, one level out: an Accordion's header, an inline
Drawer's title and aHowToStepsstep are realh1-h6because they belong in the document outline, and that is exactly the tag.proseand.vp-dochave already styled. Inside rendered Markdown an Accordion header came out at 20px where the sheet asked for 15px — taking everyem-sized glyph in the trigger with it — and a step heading at 18px and one weight heavier. All three write their type through[&.neba-heading]now, andheadingTitleClassesissheetTitleClassesin that form; the two tables cannot drift, becausetest/package/resolution.test.tsasserts one is the other with the prefix on each token.The
.neba-titlerule has the same fix. It is what lets a caller pass a real heading —<Card title={<h2>…</h2>}>— and keep the card's type, and it was written with one class plus a:where(), which is one class: inside an article the browser's own bold was being replaced by the article's rather than by the card's. Card, Alert, Empty, Collapsible, HoverCard, Toast, Timeline, Gallery, FilePicker and Fieldset all read it.Dialog,Popover,Tourand an overlayDrawerwere measured and need none of this: they render into a portal at the end of<body>, where a container'sh2rule cannot reach them.Typographystates its type scale at a specificity a host stylesheet cannot beat.h1-h6andpare the tags a CSS framework is most certain to have styled by name, and.prose h2and VitePress's.vp-doc h2both writefont-size,line-height,letter-spacingandfont-weighton them — at one class plus one tag, which a single utility cannot outrank. Measured inside a rendered-Markdown article, every level of the scale lost its leading,h1andh4their size, the headings their tracking, andweightwas ignored outright:<Typography level="h2" weight="regular">rendered at 600. The scale, the weight, the ink and theguttermargin are now written through[&.neba-typography], the same doublingTextLinkuses, andalignandlinesstay plain because nothing styles them by tag name.This changes how an override is written. A plain
className="text-[2.75rem]"no longer wins; use Tailwind's!—className="text-[2.75rem]!"— which is what the component's own docs already recommended, for the weaker reason that the two classes used to tie. There is no specificity above a host'sh2rule and below a caller's plain utility, so the only choice is which of the two wins.TableandDataTablewrite their header ink inline. Everything else a cell is painted with already was, for the reason that<th>and<td>are among the last tags a host stylesheet still styles by name, at a specificity a one-class utility cannot reach.colorwas the declaration left out, and both.vp-doc thand.prose thwrite it — so under Tailwind Typography a column heading took the heading colour, the one ink on the page chosen to stand out from the text around it. The ink reads a--n-cell-inkslot, which is also the way a caller changes it: a custom property is invisible to a host stylesheet, where atext-*class throughclassNames.headCellnever worked.A
Chipkeeps the tails of its letters. The label is truncated, and truncation clips at the line box, which the chip's one-em leading made shorter than the glyphs inside it — so every g, j, p, q and y lost its descender, at every size. The label's line box is now the font's own height. The chip is the same height and the words sit where they did.
1.13.0 (2026-09-11)
Where the bytes went
| What you import | 1.12.0 | 1.13.0 |
|---|---|---|
Button | 5.1 kB | 5.2 kB |
Chip | 3.3 kB | 3.4 kB |
LineChart | 11.6 kB | 11.8 kB |
CodeBlock | 5.0 kB | 5.1 kB |
Image | 6.5 kB | 7.1 kB |
Gallery | 10.2 kB | 10.4 kB |
| a whole page shell | 29.0 kB | 29.2 kB |
| 12 components — a typical app | 70.5 kB | 71.0 kB |
| 12 components, with Korean | 72.9 kB | 73.8 kB |
| 25 components — a large one | 115.7 kB | 116.9 kB |
| all exports | 263.9 kB | 265.8 kB |
Image is the row that moved. A picture that failed with an empty alt printed a hardcoded English sentence, so Image now reaches internal/i18n.ts and internal/defaults.ts, which costs it 0.5 kB. The last 0.1 kB is width and height, which Gallery also carries because it draws an Image.
The 0.1 kB on Chip, CodeBlock, LineChart and the page shell is the same machinery gaining an Object.hasOwn on each of its two lookups, and a shared bound under the memos in internal/.
The twelve- and twenty-five-component rows carry internal/wheel.ts on top of that, which is what a Tabs bar now takes the wheel with.
The last 0.1 kB on nearly every row is the focus ring: the resting declaration and the transition it travels on are two more entries in the class string every control carries. neba/styles.css moved 22.4 kB → 22.5 kB for the same reason, plus the breakpoint slots each responsive element now clears — which is the cost of a fix that could not be made anywhere else, since only the element that reads a slot can say it does not inherit one.
Registering a language ships that language's whole module, so the twenty picker strings and the two a Carousel's stop button needs land in it whether or not the page draws either: 2.8 kB now against 2.4 kB before. Those twenty used to be hardcoded English, which a Korean product could not reach at all.
Added
Every portalled surface reads one z-index,
--neba-z-portal. Fourteen of them carried a hardcodedz-50, which is a guess about a page the library cannot see — a site whose own fixed header sits above that had its menus and dialogs opening underneath its chrome, and the way out was a selector per surface. The token defaults to 50, and a host that needs another number sets it once on:root.A
BottomNavigationItemdeclares thetargetandrelits link already rendered. The props were typed against a<button>and cast to an<a>, so a destination that opened in a new tab could only be written by handing the component something TypeScript said was impossible.A
ProgressLinear, aProgressCircularand aMetertake athickness. The ladder decides the bar's height and the ring's stroke, and the ladder is five steps — so a speed-test dial or a page that is about one number had no way to draw a heavier line without moving the diameter with it. It is a number of pixels on all three, because that is what the ring's arithmetic already works in. A ring's stroke is held inside the ring it describes: past half the radius there is no hole left in the middle of it.A
Comboboxtakes afilter, andfalseturns it off. The list was always narrowed here, against each option's visible label — so a search answered by a server, which is the case a combobox with hundreds of options exists for, had its results filtered a second time and lost every row that had matched on a keyword, a description or a synonym rather than on the label.filter={false}lets a narrowed list through; a function decides per option. The row that offers to add what was typed is exempt from both, since it is the query written out.A
Sliderdrawsmarks, and takesclassNamesfor the parts behind its root. Labelling points on a track —1 / 100 / 250 / 500under a count, "realistic" and "abstract" at the ends of a style axis — was a row a caller had to lay out themselves, absolutely positioned at(value − min) / (max − min)of a width they had to know. An array of{ value, label? }does it, and a baremarksis a tick at everystep. The row sits beside the control rather than on the rail, because a tick on the rail has to stay legible over both the fill and the groove, and it isaria-hidden: the thumb already announces the value and the range.The slots are the other half of the same complaint. A Slider draws seven parts and offered a class name for exactly one of them, so a one-off recolour had to reach for
[role="slider"].label,control,track,indicator,thumb,descriptionandmarkare named now, like every other multi-part component in the library.A
SelectOptioncan name agroup, and a run of them gets a heading. A list of time zones by region or fonts by family had nowhere to put the heading, and the stand-in was a disabled option holding a<strong>— which the keyboard and the pointer skip correctly and a screen reader still reads as an option that cannot be chosen. A group is a run of adjacent options naming it, so the array's order stays the list's order and nothing is moved out from under a caller; two runs of one name draw two headings, which is the array saying they are not together.A
Tabsbar takes the wheel. A bar with more tabs than room scrolls, but a mouse has one wheel and it points down the page — and the bar draws no scroll buttons and hides its scrollbar, so the tabs past the edge were reachable only by keyboard. A wheel rolled over the bar now travels along it. It is on by default, which is what separates the prop fromScrollZone'swheel: that strip has a pair of buttons and this one has nothing. A bar that fits takes nothing at all, so a page with three tabs on it is unaffected, andwheel={false}turns it off.A
CarouselwithautoPlaydraws the button that stops it. It paused on hover, on focus and in a background tab, and it never started for a reader who had asked for less motion — but a reader holding a phone hovers nothing, and one running a magnifier may never put a pointer over the strip at all. WCAG 2.2.2 asks for a mechanism, and those were not one. The control sits in the row under the frame, beside the dots and never over a slide;pauseLabelandplayLabelname it, and it is translated in all nineteen languages. There is no prop to take it away — a caller who wants a control of their own can drivevalueand leaveautoPlayoff.The four date and time pickers say their twenty non-date strings in the reader's language. "Previous month", "Choose a year", "Today", "Now", "Hour", "AM/PM" and fourteen more were hardcoded English, over dates
Intlhad already translated — so a Korean product's only way out was to write out all twenty throughlabels. There is apickermessage namespace now, in all nineteen languages.labelsstill wins where it is given, which is what it is for:localeanswers the language andlabelsanswers the wording.An
Imagetakeswidthandheight, and reserves the box they describe. They were omitted from the props, so the one component in the library whose reason to exist is holding a picture's space could only be told what that space was as aratioworked out by hand — and a defaultImagereserved nothing at all, which is the largest source of layout shift on most sites. They reach the<img>as the attributes they are, and giving both turns an'auto'ratio into their proportion. An explicitratiostill outranks them: that one is the layout's shape and these two are the picture's.
Changed
A focus ring arrives rather than appears, and on a field it is flush with the edge. Two things about the same object. The ring is declared at rest with no width instead of not being declared at all, so the focus has a width to travel rather than a style to switch — it grows in beside the hairline it belongs to, at the house duration, and instantly for a reader who has asked for less motion. And a field's shell takes it flush: the hairline turns the ring's own colour the moment the focus lands, so a ring held two pixels off drew a second line with a stripe of page between the two, which is a control wearing a halo rather than an edge that has thickened. Everywhere else the ring keeps its offset, and that is contrast rather than taste — flush against a filled control it would sit on a fill of its own family.
A pointer resting on a focused field used to take the hairline a shade under the ring it is now flush against, because
hover:andfocus-within:are one class each and the winner was whichever Tailwind generated last. The pair is spelled out, so the edge is one colour.The six field shells also stop holding every duration at 0ms while the focus is inside. That was the rule about a press applied one step too far: clicking into a field is a press, but the ring is the answer to the caret being somewhere, and it arrives from a Tab as often as from a click.
An
AccordionItem's title wraps, and the header is a heading at a level the caller chooses. Two things about the same element. The title was wrapped intruncate, which is right for a label and wrong for the thing an accordion is most often a list of: an FAQ's title is a sentence, and an ellipsis in the middle of it loses the question. It wraps now, andlinesclamps it where a caller wants one — the same word, and now the same table,Typographyuses. And the header renders an<h3>whatever sits above it, so the natural way to ask for a different one was to pass a styled heading as thetitle, which nests a heading inside a heading; the documentation said to do exactly that.headingLevelon theAccordionsets it for the whole stack, which is where it belongs — the sections are siblings, and a run of headings at different levels is an outline that lies.A field's height is a floor rather than a fixed row. A TextField, a Select, a Combobox, a NumberField and the four date and time pickers all took the size ladder as an exact height, and a field holds a caller's own text — so text set larger than the step, which is the whole point of the tool that does it, had its glyphs cut off by the row around it.
classNames.shellcould repair it only with anh-auto!, and needing!importantto beat your own class is the signal that the height was the wrong shape rather than the wrong number. Nothing moves at the library's own sizes. It stays an exact height on a Button, a Chip and everything else the library draws the contents of: those are flex children of a row a caller arranges, and a minimum height there is a control that stretches to whatever is beside it.A
Typographylevel's leading is a ratio rather than a length, and the root carriesneba-typography. The two halves of one problem: the scale paired each size with the line height it worked out to, and the component emitted nothing but utilities. So a caller who set a size of their own — which is what a figure, a display number or a hero line is — got a line box built for a size nobody asked for, with no selector to repair it from a stylesheet either, since a utility string is not a contract. Every level keeps exactly the ratio it was drawn at, so nothing moves at the scale's own sizes and an overridden one gets a line box in proportion. The class is a hook and carries no styling of its own, the wayneba-linkandneba-portaldo.A
ScrollZoneand aTabsbar hold the wheel at their ends rather than handing it back. Taking the wheel and then giving it up the moment the strip runs out is what makes the page lurch mid-flick: the reader is still pushing the strip and what answers is the article behind it. Both hold it now, and the pointer leaving the strip is what gives the page its wheel back.overscroll-behavior: containdoes the same for the gestures the browser scrolls itself — a finger, a sideways trackpad swipe, and the wheel over a vertical zone — which used to carry on into the page at either end.This changes what
ScrollZone'swheelpromises. The old wording said a strip with nothing left ahead of it was something to scroll past rather than something to be caught in; a caller who wanted that behaviour wantswheeloff.An
Anchorfinds its headings once instead of on every frame of a scroll. It ran adocument.getElementByIdfor each row of the trail on every scroll frame, for an answer that changes only when the document does. The elements are kept and checked againstisConnected, so a heading that arrives after the trail is still found.An
AnimateMarqueemeasures itself when its size changes, not on every render above it. The measurement listedchildrenas a dependency, so every render of whatever held the strip tore both resize observers down, put them back, and ran agetComputedStyleand anoffsetWidth— a forced layout — for a strip that had not moved. The track is observed, so a change in what is on it is already reported.The last two
Intlobjects a render was rebuilding are memoised.localeWeekStartbuilt anIntl.DateTimeFormatand anIntl.Localeon every render of aCalendar, aDatePicker, aDateRangePickerand aDateTimePicker— every keystroke and every hover anywhere inside one — andgraphemesOf/wordsOfbuilt anIntl.Segmenteron every call, for three text effects that ask on every frame they animate. Both go through a cache now, as the number and date formatters already did.A
Transferand aTreeSelectfold their labels once instead of once per keystroke. Both folded every row inside the filter, which put aString.prototype.normalizeon every item for every character typed — aTransferdid it on every render, search or no search. The haystacks are built once peritemsnow, which is the arrangement aDataTableand aCommandPalettealready used. Nothing about what matches has changed.A chart's data table is memoised, and reads the chart's own formatter. The visually hidden table is built in the same render the crosshair's state lives in, so every cell of it was reconciled again for each pixel the pointer travelled across the picture — a row per point on a
ScatterChart. It isReact.memonow, and the frame'sformatis keyed on what the options say rather than on the identity of the object they arrived in, so aformatwritten inline in the JSX no longer defeats it.One number moves with it: a
ScatterChartwith noformatwrites its table values the way its axis and its tooltip already write them, so24000reads as24Krather than24,000. It was the only chart whose table disagreed with its own picture.
Fixed
A responsive slot no longer inherits into a nested component. Every value that changes at a breakpoint resolves through a custom property, one per breakpoint, and only the breakpoints a caller names are written — so a slot an element did not write was whatever the nearest ancestor had written. A
GridContainerinside aGridread the item's--n-span-mdand came out a fraction of the width it had asked for, and the same shape covered every gutter, column count, flex direction and content measure in the library. The element that reads a slot now clears it, which an inline style still outranks.A
NavigationMenu's panel keeps its surface. The colour slots were declared on the Root, and the panel is portalled to the end of the document — so it was not a descendant of the element carrying them,bg-(--n-panel-press)painted nothing, and the panel opened as clear glass with the page readable straight through the links. Both ends of the component take the slots now, which is what every other portalling component in the library already did.Dragging a run of rows in a
DataTableno longer selects the text it crosses. It was the last drag in the library written by hand, and it had the hole the column resize used to have: no text-selection suppression, over every cell the pointer passed rather than the two beside a boundary. It goes throughbeginPointerDragnow — captured to the table rather than to the row that was pressed, since a virtual body unmounts a row the moment it scrolls away. The auto-scroll at the edge stays where it is.A
TreeSelect's clear button says "Clear" in every language no longer. It was named out of the pickers' English defaults — a set it has nothing else to do with — rather than out of the sameactionmessages the identical × on aComboboxreads. It takes aclearLabellikeComboboxdoes, too.A pointer drag starts even when the pointer cannot be captured.
setPointerCapturethrows for a pointer that is no longer active — one lifted between thepointerdownand the handler — and the exception escaped into React's event handler, which takes the page down. Capture is an optimisation and the three listeners work without it, so it is taken where it can be and skipped where it cannot.An
AnimateScrambleredraws on its own tick and not on every render above it. The noise glyphs were picked withMath.random()during the render, so any re-render from anywhere in the tree reshuffled every unsettled letter at whatever moment it happened to land — an effect meant to be a clock, answering to the whole page. They are picked from the tick counter and the position now, which also settles the hydration mismatch the random source caused on a server-rendered page.A
CodeBlock's copy button keeps the focus when the clipboard falls back. On a page without a secure context the copy goes throughexecCommandagainst an off-screen textarea, and selecting that textarea took the focus — so a reader who pressed the button with a keyboard was left on<body>, having lost their place in the page as the reward for copying.An
Anchor, anImageand aPaneswrite their refs in an effect rather than during a render. A ref written while rendering is a ref that lies when React throws that render away, which is a live hazard under concurrent rendering and is the ruleuseShortcuthad already written down. None of the three reads the value during a render, so nothing about their behaviour changes.An
Imagesays its own absence in the reader's language. A picture that failed with an emptyaltprinted a hardcodedImage unavailable, which was the one string the library invented and could not translate. There is animagemessage namespace now, in all eighteen languages, andImagetakes alocaleand anunavailableLabellike every other component that has to invent a word.A message lookup reads a table's own keys and not its prototype's. A
localea caller took from a URL, or a{placeholder}a translation happened to name after a member ofObject, resolved up the prototype chain — solocale="constructor"was spread over English as though it were a table of messages, and{constructor}in a translation wrotefunction Object() { [native code] }into the middle of a sentence.An
AppLogoand aBottomNavigationItemmergenoopener noreferrerinto a link that leaves this tab.safeRelreached four of the six components that let atargetthrough to an<a>; these two handed the page they opened awindow.openerpointing back at the one that opened it, and aRefererheader naming it. Arelwritten by hand —nofollow,sponsored— is merged rather than replaced, as everywhere else.colorSchemeScriptescapes<in the values it writes. The string is inlined inside a<script>element, and a browser stops parsing that element at the first</scriptin it however the JavaScript around it is quoted — so astorageKeyholding one would have ended the tag and handed the rest to the HTML parser as markup.Breadcrumbalready wrote its structured data out this way.The five memos in
internal/cannot grow without bound. TheIntlformatters and segmenters, theMediaQueryLists, the week-start table and the resolved message tables are keyed on alocale, on the options behind aformatprop, or on a query string handed to the publicuseMediaQuery— a caller's to choose in every case, and two of them carried a comment claiming otherwise. They share one ceiling now, well above anything the library itself reaches; a page that never approaches it is unchanged, and one that passes it rebuilds what it would have built anyway.A shared
IntersectionObserveris let go once nothing is watching through it. One is kept perthreshold, andthresholdis a public prop on all seventeenAnimate*components — so a caller computing one left a live observer behind for every value it had ever held, each of them a registration the browser still walks on every scroll. A group now lives exactly as long as its last watcher.An
AnimateMarqueestops for the focus as well as for the pointer.pauseOnHoverwas:hoveralone, so a link on a moving strip could be aimed at with a mouse and not reached with a keyboard — tabbing to it left it travelling off the screen while it was being read.
1.12.0 (2026-09-05)
The release about the things that were already there and were not quite saying it.
Most of it came out of asking one question of every component in turn: when a state turns over, does anything happen, or is the second state simply drawn? Twenty-odd answers were "simply drawn" — a toggle whose on and off were the same colour, a checkbox whose tick was there on one frame and gone on the next, a gauge that jumped to each new reading, a spoiler that moved the page when it opened. Three were fades that had been written down and never ran.
Four components are new. Stack is a pile of things laid over each other and replaces AvatarGroup; Gallery is a set of pictures in one of four arrangements; Show and Flex are the two answers a breakpoint gives directly. There are seventeen Animate* where there were eleven, and a stagger that turns any of the nine keyframe effects into a set of them.
The last group is the same complaint one level up. The library had a breakpoint system — five widths, a per-breakpoint map, a cascade that resolves it without React hearing about it — and it reached exactly two props on two components. There was no way to draw something at one width and not another, no way to say "a row here, a column there", no way to move the widths, and no page that wrote any of it down.
Where the bytes went
| What you import | 1.11.0 | 1.12.0 |
|---|---|---|
Button | 5.1 kB | 5.1 kB |
Chip | 3.2 kB | 3.3 kB |
LineChart | 11.4 kB | 11.6 kB |
CodeBlock | 5.0 kB | 5.0 kB |
Image | 23.4 kB | 6.5 kB |
Gallery | — | 10.2 kB |
| a whole page shell | 28.5 kB | 29.0 kB |
| 12 components — a typical app | 68.2 kB | 70.5 kB |
| 12 components, with Korean | 70.7 kB | 72.9 kB |
| 25 components — a large one | 112.6 kB | 115.7 kB |
| all exports | 248.1 kB | 263.9 kB |
Image is the row that moved on purpose, and it is a new row because nothing had ever measured it: a Dialog nobody had opened was 20 kB of it. Chip is the one worth explaining, because it is the only one that moved for a reason other than "there is more library now" — transition gained a seventh effect, and the entrance vocabulary is two Records that an object literal cannot tree-shake per key, so every component offering a transition pays 0.1 kB for the row whether or not it names it. AnimateFloat and AnimateShake bring their own keyframe class instead of a row in that table, which is why it is only 0.1 kB.
all exports went up rather than down because npm run size learned to count honestly while this was being measured. It called every chunk but the entry deferred, which stopped being true the moment a module was imported both statically and dynamically. It walks the static import graph now, so a number in the table is what a page needs before it draws.
The rest of the movement is one dependency. @base-ui/react is on 1.8.0, and pinning it back to 1.7.0 returns every row to the figure this table carried before it — so the 1.4 kB on a twelve-component app and the 2.5 kB on all exports are its rather than ours. Nothing that draws a single component moved by more than 0.1 kB.
Breaking changes
AvatarGroupis removed.Stackdoes everything it did and is not about avatars. What is lost is its context —size,shape,variant,colorandelevationset once for the group. Set them on the avatars, or on aNebaProviderfor the two it covers.
Added
Stack— a pile of things laid over each other.directionruns it along the inline axis, down the page, or diagonally as a fanned deck;overlapanddropsay how far each item sits under the last;max,totalandoverflowturn the ones that did not fit into one more item at the back;scaleStepandopacityStepmake the pile recede;ringdraws the hairline that stops two overlapping shapes of similar tone reading as one smeared one.The overlap is a margin rather than a
translate, so the box is exactly as big as what is in it and the content after a Stack is laid out against the right width. Each item is drawn into a wrapper of its own rather than cloned onto, because a Tooltip around an avatar is under no obligation to accept aclassName.tsx<Stack ring max={3} total={12} overflow={(hidden) => <Avatar initials={`+${hidden}`} />}> {team.map((name) => ( <Avatar key={name} name={name} /> ))} </Stack>Gallery— a set of pictures, and the arrangement is a prop rather than four components.gridis a contact sheet;masonrykeeps each picture's own proportion and stacks the columns;justifiedkeeps the proportions and fills every row to the edge, which is the only arrangement where nothing is cropped and nothing is left over;quiltedis a grid whose tiles may take more than one cell.None of them measures anything. A tile's shape is the item's own
ratio, so the wall is right in the first frame the browser paints and does not move again as the files land, andjustifiedis a wrapping flex row the browser does the arithmetic for.masonryis the one layout that needs a number in JavaScript, and it deals each item into the shortest column rather than filling the first one top to bottom.columnstakes a breakpoint map andgapa step, a number or a length.captionputs an item's title and description below the picture, across the foot of it, or under the pointer;hoveranswers with depth, colour or the one scale the design language allows;previewopens the picture full size with the rest of the set on the arrow keys, in a chunk that is not fetched at all unless the prop is on.filter,frame,watermarkandprotectpass straight through to every tile'sImage, and the last two follow the picture into the viewer. Thegallerymessage namespace is new, in all eighteen languages.Show— its children at some widths and not at others.aboveis an inclusive floor andbelowan exclusive ceiling, so the same breakpoint in both covers every width exactly once, and the two together bound a range.tsx<Show above="md"> <Sidebar /> </Show> <Show below="md"> <SidebarTrigger /> </Show>The children are always rendered and what changes is
display, which is what makes the answer right in the first frame, the same on a server, and free on a resize. What it deliberately cannot do is stop something running;useBreakpointalready could. The wrapper isdisplay: contents, so aShowbetween aGridContainerand aGridleaves the cell a cell.Flex— a row that becomes a column. "Side by side on a desktop, stacked on a phone" is half of what a responsive layout is, and the only way to say it was aGridContainerwith aGridaround each child.directiontakeshorizontal/verticalrather than CSS's four values, so a Flex and a Stack say the same thing the same way, and it takes a breakpoint map —direction={{ xs: 'vertical', md: 'horizontal' }}.spacing,rowSpacingandcolumnSpacingare aGridContainer's props on its scale, so a gutter is one number across the two. It draws nothing at all, andwrapis off by default.Six more
Animate*—AnimateReveal, an edge travelling across content that is already in place, as aclip-path, so nothing reflows and it is also atransitionvalue;AnimateFloat, a slow drift with nowhere to get to;AnimateShake, the one effect that says no, which defaults totrigger="manual"and is the single documented exception to the rule that a control is never transformed;AnimateSplit, a line arriving a word or a letter at a time;AnimateCounter, a number counted up to its value and formatted withIntl.NumberFormatoptions on every frame;AnimateScramble, text resolving out of noise in a box that is the finished length from the first frame.The three that animate text put the whole string in the document once for a screen reader and hide the performance from it, which is
AnimateTyping's arrangement, and all four now take their grapheme boundaries frominternal/text.ts.stagger,durationStepandreverseon the nineAnimate*whose motion is one@keyframeson the element itself. At0— the default — the box animates and the children are left alone, which is what they always did. Above it the effect moves onto each child in turn and nothing is written on the box, because a box fading in over eight children fading in is the same content faded twice.AnimateAppearruns on the same helper now, which is what stops the library having two staggers.timeline="view"— any of the nine keyframe effects driven by how far the element has travelled through the viewport instead of by the clock. It is two declarations behind an@supports. It costsduration,delay,repeatand everytrigger, and falls back to running once on mount where the browser has noanimation-timeline.useBreakpointValueanduseCurrentBreakpoint— a breakpoint map read in JavaScript exactly as the cascade reads it, for a caller working out a number for themselves.undefinedis a real answer: a map that has said nothing yet at this width is an opinion declined, which is what the CSS fallback says there too.Four things an
Imagecould not do, none of which costs anything until it is asked for.filtercolours the picture —grayscale,sepia,invert,saturate,mute,contrast, or a CSS chain of your own — and rides the same transition as the picture's own fade.frameis how the picture is mounted, a silhouette on its own or the whole arrangement ofcorner,border,mat,background,elevationandfeather; the line is an inset shadow on a layer over the picture rather than aborder, which is what lets it follow a chamfered corner and keeps it out of the layout.watermarkdraws a mark in a corner or tiled across the whole picture, as one SVG background rather than a wall of elements.protectturns off the right-click menu, the drag, the iOS long press and the selection — a deterrent and not a lock, and the docs say so where the prop is described.overflowandlinesonTabs—overflow="wrap"is for a bar whose tabs all have to be visible at once, and the rule under the chosen tab moves onto the line that tab is on rather than staying at the bottom of the list.linescaps a wrapping bar at that many tab-rows.maxWidthtakes a length and a breakpoint map onContainer,HeaderandFooter, which had a copy each of the measure ladder and could only ever be one of its five values at one width. Anything that is not a step of the ladder goes straight tomax-width, so'60ch'and'min(90vw, 72rem)'need no escape hatch and a number is pixels. The ladder is namedNebaMeasurerather than left asNebaSize, because it shares five names withNebaBreakpointand only four of its five values.Statistic'svaluetakes a node, which is where anAnimateCounterbelongs.
Changed
The library's media queries are
theme(--breakpoint-*), so a Tailwind project moves Neba's widths by moving its own.@mediacannot read a custom property, so a breakpoint stays a build-time decision and no provider prop could ever move one — but it can be a decision you take part in.css@import 'tailwindcss'; @import 'neba/tailwind.css'; @theme { --breakpoint-md: 50rem; }That moves the library's own rules, the
md:variants its components spell out, and its JavaScript, which reads the resolved widths back off the document rather than holding a second copy. Before this, redeclaring--breakpoint-mdmoved your utilities and left Neba at 48rem in both places. A project onneba/styles.cssgets the widths baked in, since that sheet is compiled here.A
Togglethat is on now looks like it. Off wasaccent 8%and on wasaccent 10%— two names for one colour, with the ink carrying the whole state. Off leaves the colour family entirely now for the neutral--neba-panelladder, and on takes the dyed--n-panel-pressthat draws aSegmentedButton's chosen segment. A control with two states cannot spend the family on the one that is false.Home,End,PageUpandPageDownscroll aDataTableand nothing else. They used to move the selection with them, so ticking a row and then looking at the bottom of a thousand threw the tick away on the journey. The arrows still move and choose. A table with noheightormaxHeightleaves all four to the browser rather than taking them and turning them into nothing.A
Spoileris the same height covered and uncovered.reversiblegrew the box by a whole button row at the moment of the press, and the cover — anabsolute inset-0layer contributing no height — lost its own reveal button off the bottom edge whenever it was taller than what it covered. The row is drawn from the start and kept out of sight, and the cover is a grid item spanning every row that keeps its row on the press, giving up only the paint and the tab stop.maxHeightstays the deliberate exception, since releasing the clamp is the whole point of it.A tab bar says when it has more bar. It already scrolled; the scrollbar is an overlay on macOS and furniture on Windows, so it is hidden on both and the ends fade instead, through the same two masks
ScrollAreaalready uses.A drawer comes in from its edge. It faded, which moves nothing and throws away the only thing that distinguishes it from a
Dialog— and aSidebarbelow its breakpoint is a drawer, so the same thing happened to every collapsed page shell. The panel travels ontranslatenow and the scrim behind it still fades. It runs on--neba-durationrather than the window ladder's 240ms, because--neba-duration-windowis not zeroed underprefers-reduced-motionand a drawer sliding across the screen is exactly what that setting is asking not to see.A
GaugeChartsweeps to its reading. It drew the reading as a wedge, and a wedge is a closed shape, so moving the value rewrote itsd— not a property CSS can travel along. It is a stroke along the middle of the groove now, whose drawn length isstroke-dashoffsetwithpathLength="1". The shape on screen is identical; onlyfillmoved tostroke.An
Imageweighs 6.5 kB instead of 23.4. ADialogwas most of what it cost, andpreview, the only thing that opens one, is off by default. It is fetched on demand now, the wayCodeBlockfetches a grammar.npm testruns the suite in sessions of at most fifty files, each its own browser, throughscripts/run-tests.mjs—ceil(files / 50), so adding tests does not need the number touched. Nothing is skipped and nothing is retried: a failing test still fails its shard and still fails the run.npm run buildemptiesdist/first. It never did, andtsconly ever writes, so a component deleted fromsrc/stayed indist/and shipped. RemovingAvatarGroupis what surfaced it.ScrollZone'sbuttons="auto"disables an inline button that has nowhere to go rather than hiding it. The lane is held open either way, so an emptied one was not a lighter row — it was the same row reading as stray padding at the leading edge, which is the state every reader meets first.@base-ui/reactis on 1.8.0, which is the floordependenciesdeclares. It is the only runtime dependency that moved, and the multi-component rows of the table above are where it shows.
Fixed
A
Select's and aCombobox's popup arrived in one frame while the calendar hanging off theDatePickerbeside them took 160ms. The fade was fourteen identical copies of one declaration; it ispopupFadeClassesininternal/styles.tsnow, read by sixteen surfaces, and the two that were missing it got it by being written the same way as the rest.NavigationMenukeeps its owntransition, because its panel also changes size and a second shorthand beside its own would win or lose by stylesheet order.Three fades were written down and never ran.
transitionClasseslists the four properties a control answers a pointer with, andopacityis not one of them. AnImagecarried a comment saying the picture is faded in over a transition that could not fade it; a chart's legend dimming switched rather than faded; and a chart's other series dropped to 0.28 the frame the pointer crossed a legend row.LineChart,AreaChart,BarChartandScatterChartnow read the sameseriesDimClassesthat onlyPieCharthad.A
Checkbox's tick and aRadio's dot appear over time. The tick draws itself along its own length onstroke-dashoffset, over a path normalised withpathLength="1"so one number covers both the tick and the indeterminate dash; the dot grows out of the centre of its ring onwidthandheight. Neither scales, and the design language now names the four things allowed to travel inside a control — a Switch's thumb, a Checkbox's tick, a Radio's dot, a Rating's fill — and says the list is closed.A chart's marks answer the pointer on one
transitionshorthand. A whole series at 0.28 and a single datum at 0.92 are the same sentence at two scales, and the datum half snapped everywhere the series half now fades; the mark under the crosshair also grows by a pixel, which a<circle>reaches throughrand a scatter's arbitrary<path>reaches throughscale. The scatter mark grows about the point it is pinned to rather than the middle of its bounding box. All three ridemarkTransitionClasses, because two shorthands on one element are decided by stylesheet order rather than by intent.Four more places where something changed and nothing moved. A
Rating's fill travels onwidth— the same width on the same element, so no glyph is scaled. ATabpanel's arriving content fades up, and only the arriving one, since fading a leaving panel would put both in the layout and make the sheet twice as tall on the way past. AnAvatar's picture fades up on its own clock instead of swapping in on one frame, which on a list of forty was forty separate flickers. AFloatingActionButton's dial no longer arrives in a single frame.A
TreeViewbranch opens at a height. It was there on one frame and gone on the next, whileAccordionandCollapsible, which do the same thing, both travel. It is a grid row going from0frto1fr, so nothing is measured and a nested branch is carried by the same track. A branch on its way shut stays in the document but is markeddata-closing, which keeps its rows out of the order the arrow keys walk and deregisters them on the same render that shut the branch.Transfersays where the rows went. A press on the arrow took three rows off one list and put them in the other in a single frame, so the only way to find them was to read the whole panel again. The rows that landed fade up — keyed on the press and not on the list changing, becauserowsalso changes on every keystroke in the search box and a filter that animates is a filter that feels slow.A
GaugeChart's reading and range labels stay inside the dial. The reading was twice the tick type whatever it said, so10,000%was written straight across the band and out of the card; it is solved against a chord of the inner circle now and gives way to once the tick type before it is left to run. The two range labels were written from the arc's mid radius and lay over the band on a thick dial; they are set from the outer edge, in the arrangement the end calls for, and a dial closed past 330° writes none. The dial is also centred in the box rather than pinned under its top margin.A picture that had already decoded stayed invisible. An
<img>firesloadat whoever is listening at the time, and a data URI decodes inside the same task the element was inserted in — so the event went out before React had attached anything to catch it and the picture sat atopacity: 0behind its own placeholder for good. It is asked after the fact now:completesays whether it finished andnaturalWidthsays which way.A
Drawerininlinemode and a collapsedSidebardropped the props they were handed. Only theoverlayshape spread them, so anid, adata-*or anaria-*reached the panel in one mode and not the other — and on aSidebarthe mode is the window's choice rather than the caller's, so an attribute was there on the screen you developed against and gone on the screen you did not. A structural rule for this was tried and rejected:resolution.test.tscan ask whether one element both spreads and writes an attribute of its own, but "every tree this component can return forwards what it was handed" is a question about branches, and every regex shape of it produced false positives. It is three ordinary tests instead.A
HowToStepsrow said its title twice. The sentence naming the step sat in a visually hidden line beside the title rather than in place of it, so a screen reader announced the button as "Use it Step 3: Use it". It is the button'saria-labelnow. Atitlethat is a node leaves no string to build that sentence out of, and such a row is read as its contents.GridContainer's axis gutters took their prop whole.spacing={2} columnSpacing={{ md: 6 }}left the row with no column gutter at all below 48rem, because the map says nothing there and the baseline it fell back to was the prop's own default rather than thespacingbeside it. The two are walked together now, andFlexuses the same fold.The suite finishes.
run-testhad been red onmainfor weeks, always the same way: the chromium jobs failed on every runner while firefox and webkit passed on all three. It is not a test — every test that starts passes, and chromium loses the browser somewhere past the hundredth file of a hundred and forty-six, so the file count comes up short beside a full test count. Twelve full runs, none finished; the only lever that separated a run which finished from one which did not was how many files a single browser session was asked to hold. See the sharding above; eighteen sharded runs since, all green.npm run docs:buildneeded more heap than Node gives by default. A hundred and thirty-nine pages with two hundred React demos behind them is past 4.3 GB, and it failed as a V8Abort trap: 6inside Rollup that named no page at all.
Documentation
A new breakpoints page, which is where the rule that every entry is a floor, the table of which props are responsive, and the reason
sizeandvariantare not among them all live.The design language names its two exceptions rather than leaving them to be found: the closed list of four indicators allowed to travel inside a control, and the drawer that arrives on
translatewhere every other floating surface only fades.
1.11.0 (2026-08-31)
The release that closes the gaps, rather than the one that adds a shelf of new components.
It started as an audit against the libraries people arrive here from — MUI, Ant Design, Chakra, Mantine, Radix, PrimeReact — and the answer was not the component list. Neba already ships charts, animations, a page shell and a data table that most of them charge for or split into a second package. What it was missing was smaller and felt harder: no way to say "this application is compact" once, no hooks, no imperative confirm, no standalone calendar, and a TextField you could hand an onKeyDown but could not actually make act on a key.
Nine of those are closed here. The one deliberately left open is a rich text editor, which is somebody else's package.
Where the bytes went
| What you import | 1.10.0 | 1.11.0 |
|---|---|---|
Divider | 3.0 kB | 3.2 kB |
Button | 5.0 kB | 5.1 kB |
Chip | 3.0 kB | 3.2 kB |
LineChart | 11.3 kB | 11.4 kB |
CodeBlock | 4.9 kB | 5.0 kB |
| a whole page shell | 28.4 kB | 28.5 kB |
| 12 components — a typical app | 67.3 kB | 68.2 kB |
| 12 components, with Korean | 69.9 kB | 70.7 kB |
| 25 components — a large one | 111.7 kB | 112.6 kB |
| all exports | 240.7 kB | 248.1 kB |
Every component grew by about 0.2 kB, and it is one thing. NebaProvider fills in the props a call site left out, which means every component that takes size, density, variant or locale now reads a context before its own destructuring — a hundred and thirty of them. internal/defaults.ts is 0.2 kB gzipped and it is a fixed cost even on a page that has no provider, which on a Chip is five per cent. It is stated here rather than left to be found, and CLAUDE.md now carries it as the sixth thing that holds the bundle numbers in place: whatever goes into that module goes into all of them.
The rest of the growth is the four new components and the hooks, and it lands where it should — on all exports and nowhere else.
neba/styles.css moved 20.8 → 21.0 kB gzipped.
A DatePicker has always drawn all three grids. The month name opened twelve months, the year opened twelve years, and both were only ever a way of reaching a day — so a product that wanted a billing period or a tax year got a control that showed it the twelve months and then insisted on a date inside one of them. granularity makes one of those grids the answer.
And a field has always accepted an onKeyDown, which is not the same as being able to act on a key. On a Combobox the keys worth acting on are the list's and never arrive; on a NumberField the handler lands on the column holding the label rather than on the <input>. shortcuts is { 'Mod+Enter': send } bound to the control itself, written in the vocabulary Shortcut already draws — which turned out to be a vocabulary the library only half spoke.
internal/keys.ts is 0.6 kB of the field rows above, and it is carried whether or not a shortcuts map was passed: the alias table and the predicate are reached from the control's own key handler, so there is nothing for a bundler to drop.
Added
granularityonDatePicker—'day'(the default, unchanged),'month'or'year'. At the two coarser settings the calendar opens on that grid and a click there is the answer; there is no day view to fall into. Climbing is untouched, so a month picker still reaches any month of any year in two clicks.The value stays a
Date, normalised to the first day of what was chosen — 1 March, 1 January. A second value type would have meant a second set of props to compare it with, andminDate/maxDatealready speakDate.Four things follow the unit rather than being left to the caller to keep in step. The trigger's default
formatbecomes{ year: 'numeric', month: 'long' }or{ year: 'numeric' }—dateStyle: 'medium'on a month picker printsMar 1, 2026, which names a day nobody chose in the one place a reader actually looks. The footer's shortcut says "This month" or "This year".namesubmitsYYYY-MMorYYYY, the shape a native<input type="month">already submits, rather than a day the server would have to know to ignore. And the bounds are read at the unit.thisMonthandthisYearonPickerLabels— the two strings that footer needs. Both have English defaults, like the eighteen already there.shortcutsonTextField,NumberFieldandCombobox— a map from a key combination to what it does, spelled the wayShortcutdraws it, so the key a form shows a reader and the key it binds are one string.tsx<TextField label="Message" multiline shortcuts={{ 'Mod+Enter': (event) => { event.preventDefault(); send(); }, Escape: clear }} />A map rather than an
onShortcut(combination, event), because a caller with three shortcuts wants three functions and not aswitch. Modifiers are matched exactly, soEnterandMod+Enterare two entries that never both fire and no entry is ambiguous.It is bound to the control, which is the half
onKeyDowncould not do. On aTextFieldthat only movescurrentTargetonto the<input>; on aNumberFieldit is the difference between the field and the column the label sits in; on aComboboxit is the only way in at all, because the arrows,EscapeandEnterbelong to the list and are gone before anything on the root sees them.onKeyDownstill receives every keystroke and runs after the map — neither prop replaces the other, and nothing is prevented on your behalf.This is deliberately three components and not thirteen. It is for a control a reader types into, where a key is a thing the control already has an opinion about. Everything else already takes the handler it needs.
NebaShortcutsinsrc/types.ts— the type behind that prop, generic in the element soevent.currentTarget.valueis typed without a cast.NebaProvider— one optional place to set what every component under it starts from, and the gap that costs the most on every project while showing up in no bundle number.defaultsfills insize,density,variantandlocalewhere a call site left them out, and the call site still wins: caller, then provider, then the component's own literal. The list is closed.coloris out because a component's colour default is often semantic — an Alert isinfo, a Popconfirm isdanger— and one global override would repaint those into something that means something else;elevationis out because a shadow is opt-in per surface, and an application-wide one is the moulded-plastic look the design language is against.The colour scheme writes
data-themeandcolor-schemeon<html>; the second is what turns the browser's own scrollbars and form controls over, and a page that changes only its own colours keeps a white scrollbar down the side of a dark one.useColorScheme()keepssystemas its own answer andresolvedColorSchemenever is, because a three-way switch has to showsystemas a position rather than as whichever of the two it resolves to.colorSchemeScript()is exported for the first-paint flash React cannot prevent, and shares the provider's key and attribute rather than being a snippet in a page nobody updates.directionsetsdirand wraps Base UI'sDirectionProvider, and is left alone when not given, so a document that already sets it server-side is not fought over.Seven hooks, from
neba/hooksand the barrel —useDisclosure,useMediaQuery,useBreakpoint,usePrefersReducedMotion,useElementSize,useOnScreen,useShortcut.Every one is machinery the library already runs on, which is the whole selection rule: there is no general-purpose hook collection here and there is not going to be one.
useDisclosureis the caller's half of theopen/onOpenChangepair every overlay takes;useMediaQueryis the store PageLayout subscribes to, one liveMediaQueryListper query for the page;useElementSizeanduseOnScreenare the two shared observers;useShortcutis what CommandPalette binds its own opener with.Calendar— the pickers' grid, inline. It has been ininternal/since the first picker shipped, which meant a page wanting a month on it had to open a DatePicker and never close it.modeissingle,multipleorrange; a second click on a held day takes it out, and a range click below the start begins a new span rather than inverting the old one.renderDayputs a dot or a count under a number. It is not a scheduler, and the docs say so: the cells are the control ladder's heights.TreeSelect— the gap betweenSelectandTreeView.selectableBranchesis off by default because in most of these trees the branches are the taxonomy and the leaves are the answers, andsearchablekeeps every ancestor of a match and opens the branches it kept — a tree filtered to bare matches is a list, which is what the tree was chosen over.Image— the three things a bare<img>leaves to whoever wrote it:ratioreserves the box, a Skeleton stands in while the file arrives, and a box carrying thealtis drawn if it does not.altis required by the type, because a missing one and an empty one mean different things and only the second is ever correct.ConfirmProvideranduseConfirm— "are you sure?" as something you await. A promise rather than anonConfirm, because the code that asks is the code that acts. It never rejects; cancelling,Escapeand the backdrop all resolvefalse. Questions queue rather than replacing each other: resolving an older one to make room reports an answer nobody gave, which at the call site reads as "they said no".Popconfirm— the same question beside the control that raised it. The choice between the two is reach, not danger or size.VisuallyHiddenandPortal— two things the library has needed everywhere and kept to itself.VisuallyHiddenis the 1px clipped box behind a Chip's × and a chart's screen-reader table;Portaladds theneba-portalclass a scoped stylesheet finds a portalled subtree by, which is the reason to reach for it overcreatePortal.Five things on
DataTable— column pinning,columnOrderand drag-to-reorder, in-place cell editing,groupBywith per-columnaggregate, and CSV export. Each carries its own sharp edge in the docs: a pinned column moves to its edge, an order that does not name a key leaves it alone, editing needs both a column that allows it and a handler above it, grouping turns virtual scrolling off, and an export is every row the search and sort left rather than the page the reader is on.
Fixed
A folded group in
DataTablekept its rows but lost its own heading, so there was no way to unfold it. Caught by its own test on the way in; the body now renders group by group rather than deriving headings from the rows it can see.shortcutonCommandPalettenow binds every spellingShortcutdraws.Cmd+K,Command+K,Meta+KandEscall rendered a correct key cap and none of them fired;Ctrl+Kwas dead on Windows and Linux, because the matcher folded anyCtrlinto the platform'sModand then found noModin what it had been given. OnlyMod+…and a bare key ever worked.The cause was two spellings of one idea: the drawing side had an alias table and the binding side had a five-line predicate written separately.
src/internal/keys.tsis now the one place that decides which key a token names, and both sides read it — the aliases, the platform, and theAlt-on-a-Mac case whereevent.keyreports˚and the physical key has to be consulted instead. What a key looks like stays inShortcut.
Changed
minDate,maxDateandshouldDisableDateonDatePickerare read atgranularity. A minimum of 15 March leaves March pickable atmonth, since part of March is allowed, andshouldDisableDateis handed the value the cell would produce — the 1st, rather than a day inside the month it is being asked about.Nothing changes at
day, which is what every existing call is:isUnitOutsideat day granularity is theisDayOutsideit was before, character for character. The month grid was already making this comparison inline — it is the rule that keeps a month whoseminDatefalls inside it reachable — so the two coarser grids now call one function instead of restating it, and the footer's shortcut asks the same question the cells do.shouldDisableDatereaches a coarser grid only when that grid is the one being chosen from. Atdaya callback blocking weekends must not grey out every month whose 1st happens to be a Saturday.Every component reads its props through
useStyleDefaultsbefore its own destructuring. That is what makesNebaProvider's precedence come out as caller → provider → literal, and it is why every row of the table above moved.The keys are passed in per component rather than worked out, and that is load-bearing: a key a component does not destructure stays in the props it spreads onto its root, so filling
densityinto one that has none would putdensity="compact"on a<div>— andsizeon an<input>is a real attribute that would quietly resize the field.internal/media.tsowns the five breakpoint widths, which used to live inpage-layout.ts. A layout asks "narrower than this" and a caller asks "at least this"; two tables would be two chances to disagree about whatmdis.
1.10.0 (2026-08-30)
No new components. This one is about the question that follows installing a component library and using it for a week: how do I change how this looks, when the design language and I disagree about one thing on one screen.
className was never the missing half — every component already took one and merged it with its own. What it could never reach was everything behind the root. A TextField's className lands on the column holding the label, the shell and the two lines under it, which means the <input> itself had no name a caller could use; a Table's lands on the sheet the table scrolls inside, leaving the <table>, its header band, its rows and its cells unreachable; and a Select's popup, a Dialog's backdrop and a Tour's mask all render at the end of <body>, outside the element className reaches, so no selector written against the root would ever find them. Thirteen components now take a classNames map — seventy-six named parts between them.
The standing scenarios say what that cost the people who do not use it:
| What you import | 1.9.0 | 1.10.0 |
|---|---|---|
Button | 5.0 kB | 5.0 kB |
Chip | 3.0 kB | 3.0 kB |
LineChart | 11.3 kB | 11.3 kB |
CodeBlock | 4.9 kB | 4.9 kB |
| a whole page shell | 28.4 kB | 28.4 kB |
| 12 components — a typical app | 67.2 kB | 67.3 kB |
| 12 components, with Korean | 69.7 kB | 69.9 kB |
| 25 components — a large one | 111.4 kB | 111.7 kB |
| all exports | 240.3 kB | 240.7 kB |
A slot is one more argument into a cx() that was already being called, so nothing was added to the runtime and the five unchanged rows did not move. neba/styles.css does not move at all — there is no new CSS in this release. The 1.9.0 column is the budget as last recorded, which for two rows is a tenth or two above the table in those release notes — four commits of perf and refactor work landed after they were written.
Added
classNameson thirteen components —TextField,NumberField,Select,Combobox,Checkbox,Switch,RadioGroup,Radio,Table,Dialog,ToastProvider,TourandCommandPalette. One class name per part, merged with the component's own rather than replacing it.There is never a
rootkey.classNameis the root, on every component in the library, and aclassNames.rootbeside it would be a second spelling of an idea that already has one — which is the rulesrc/types.tsexists to hold.NebaSlotsandNebaFieldSlotare in that file for the same reasonNebaStylePropsis:label,control,descriptionanderrormean the same four things on aTextField, aSelect, aCheckboxand aRadioGroup, and what a component adds past them lives with the component.The slots worth knowing about are the ones with no other way in.
Select's andCombobox'spopupanditem,Dialog's andCommandPalette'sbackdropandviewport, andTour'smaskare all portalled or siblings of the elementclassNamelands on. A descendant selector written against the root does not reach any of them, and before this there was nothing that did.classNameonTourandCommandPalette, on the card and on the sheet — the same elementDialogputs one on, because in all three that is what a caller means when they name the component.ToastProviderstill takes none, and that is the answer rather than an omission: it renders no element of its own — it wraps the application and puts a portalled stack on the page — so there is nothing for a root class name to land on. Itsviewportandtoastare slots instead.
Changed
A
Tablecell's padding, alignment and background are still inline styles, and now say so. They have to be —.vp-doc tdand.prose tdoutrank any one-class utility, which is why the styling moved inline in the first place — so a class handed tocell,headCelloremptycan add anything the component does not already set inline and needs an important utility (p-4!) to change what it does. The slot type and the component page both carry that caveat rather than leaving it to be discovered.ToastProvider's description no longer builds an empty class name out of a nested ternary. Same output, one condition instead of two.
Fixed
npm run size:updateno longer leaves the working tree failingprettier --check. It serialised withJSON.stringify(…, 2), which puts"imports": ["Button"]on three lines where Prettier wants one; the file stayed unformatted from the moment the script finished until something else rewrote it, and the only reason that was survivable is thatnpm run buildrunsformat:fixfirst, so it was usually undone by accident before anyone looked. The formatter now does the layout, loaded on that path only so the check path — the one CI runs — does not pay for a formatter it never calls.
Documentation
The getting-started guide stopped promising the wrong thing. It told a Tailwind user that a
classNamethey pass "sorts correctly against the component's own classes", which reads as a promise that theirs wins. Same-pass generation is only what lets the two be ordered against each other at all; the order is Tailwind's own, so a component'sh-10beats a caller'sh-8and itsrounded-lgbeats a caller'srounded-full, whilebg-red-500wins — decided by the value rather than by who wrote it. The important modifier (h-8!) is the form that always wins, and it is now documented as such.A new section in prop conventions covering all three channels:
classNameon the root,classNameson the parts behind it, andstylewriting one of the hundred-odd--n-*custom properties a component reads its colour and depth out of. The last of those is the one override in the library that cannot lose — a caller'sstyleis merged after the component's own, and an inline custom property has no cascade to compete in.Twenty-four component pages — twelve components in both locales — gain a
classNamessection, with their rows in the props tables.test/package/resolution.test.tsnow holds the override contract, which fails the way everything else in that file fails: silently, and in someone else's project. Four invariants — that no component drops theclassNameorstyleit was handed into a props spread, in JSX and in thepropsobjectuseRendertakes; that a slot union is declared beside the component that offers it; that it never namesroot; and that every slot offered is actually read. A slot that type-checks, reads as supported and does nothing is exactly what this catches.Thirty-nine new test cases across thirteen files, taking the suite to 2,466.
1.9.0 (2026-08-30)
Sixteen components, and they come from one question asked properly: what does a large application still have to write by hand after installing this? The answer was in two places. Nine of them were Base UI primitives that had simply never been wrapped — a toggle and its group, a meter, a menu bar, a navigation menu, a hover card, a scroll area, a form and its fieldset. The other seven are the components a product team writes itself around the third month: a command palette, a guided tour, a two-list transfer, a table of contents that follows the scroll, a key-value panel, a stack of avatars, and a gauge.
Twenty new exports, and the standing scenarios say what they cost the people who do not import them:
| What you import | 1.8.1 | 1.9.0 |
|---|---|---|
Button | 5.0 kB | 5.0 kB |
Chip | 3.0 kB | 3.0 kB |
LineChart | 11.0 kB | 11.0 kB |
| a whole page shell | 28.1 kB | 28.1 kB |
| 12 components — a typical app | 67.0 kB | 67.1 kB |
| 25 components — a large one | 110.8 kB | 111.4 kB |
DataList | — | 1.4 kB |
Anchor | — | 2.0 kB |
Meter | — | 3.9 kB |
Form with Fieldset | — | 3.9 kB |
GaugeChart | — | 5.4 kB |
CommandPalette | — | 36.6 kB |
| all exports (126 → 146) | 215.3 kB | 239.8 kB |
The four unchanged rows are the ones worth reading: twenty new exports, and a bundle that did not ask for them is the same size to the byte. The two application-sized rows moved by 0.1 kB and 0.6 kB, and for a reason that is in Changed below rather than for anything that stopped being shaken out — every field component now renders the message its validity already had.
The spread in the new rows is Base UI rather than Neba. DataList is a <dl> and weighs what one weighs; CommandPalette is a modal dialog over an autocomplete, and the floating machinery underneath it is most of thirty-six kilobytes — shared with Menu, Select, Popover and Dialog the moment a page uses any of them. neba/styles.css moves 19.7 kB → 20.8 kB gzipped.
Added
ToggleandToggleGroup. A button that stays down. The difference from aSwitchis what the press is — a switch changes a setting and the change is the point, a toggle changes the state of the thing beside it: bold on the selected words, the grid on the canvas, the filter on the list. The difference from aCheckboxis that this one is a control rather than an answer, so it never goes in a form.variantsays how the toggle looks while it is off, and on is always the colour family asserting itself. That is the whole design: the ink at rest is--neba-muted-fgin all three weights, because a Button at rest is an action waiting to be taken while a toggle at rest is a state that is currently false, and accent ink on an unpressed toggle says it is on. With no children it goes square around its icon, which is the shape a toolbar wants.ToggleGroupsquares off the corners facing a neighbour exactly asButtonGroupdoes, owns the value as an array in both the single and the multiple case, and setsvariant,size,color,density,elevationanddisabledonce for the set. It provides the same context aButtonGroupprovides rather than a second one spelled identically, so aToggledropped into aButtonGrouppicks the set up too.Meter. How much of something there is, on a scale known in advance — disk used, seats taken, quota spent, a password's strength. It looks exactly like aProgressLinearand is not one: a progress bar is about time, so it may have no value at all and is expected to move on its own, while a meter is about quantity and does not move unless the thing it measures does.valueis therefore required, androle="meter"rather thanrole="progressbar".thresholdsis the reason it earns a component. A meter's whole job is that where the value sits is what it means — 40% of a disk is fine, 95% is a page — and left to the caller that is a ternary at every call site, with the fourth one disagreeing about where amber starts.GaugeChart. The same reading bent into an arc, and deliberately the same component in two shapes:value,min,maxandthresholdsmean exactly what they mean on aMeter, so a figure can move from a bar to a dial without changing what it says. Reach for the bar in a row of fields and for the dial in a tile of its own, where it reads from across a room and four pixels of bar does not.sweepopens the dial symmetrically about twelve o'clock —180for a dashboard tile,270for the instrument shape,360for a ring — and the drawing is sized against the box for the sweep it was given, so a half-dial leaves no empty half above it. It is not aPieChartwithshape="semi": a pie is parts of a whole and every slice is a category, while the unfilled part of a gauge is not a second category, it is the rest of the dial. The reading in the middle is real text rather than an SVG<text>, so it is selectable, findable and in the accessibility tree.Menubar. The strip of words at the top of an application — File, Edit, View. What makes it a bar rather than a row of separate menus is what happens once one is open: moving along the strip walks through the others instead of closing the one you left, and the arrow keys move between the menus as well as inside them.MenubarMenutakes alabeland the sameMenuItem,MenuGroup,MenuSeparator,MenuCheckboxItem,MenuRadioGroupandMenuSubmenuaMenutakes, because it is the same menu.It draws no surface, and the words sit on a ladder one rung below the control heights at every step. A menu bar is a strip of words rather than a row of buttons, and it is always on something that already has a height — a
Toolbar, aWindowPane's title bar, aHeader. Sized as controls,File Edit Viewwould make the bar taller than the thing it is drawn on.NavigationMenu. A site's navigation: a row of destinations, some of which open a panel of more of them. The difference from aMenuis what the rows are. A menu holds actions, so its rows aremenuitems and the whole thing is a widget. This holds links, so it is a<nav>full of real<a href>s — which is what puts them in the link list a screen reader pulls up, on the status bar, in a "open in new tab", and in a crawler's index. An item with children is a trigger and a panel; an item with anhrefand nothing else is a link, and the two are announced differently.One panel is open at a time and it resizes between items rather than closing and reopening, so crossing the row reads as one surface rather than three.
columnslays a wide panel out in two or three.CommandPalette. Everything an application can do, behind one field — the shape a keyboard-first product takes once it has more actions than a menu bar can hold. Not aMenu, which is a short list in one place where every row is visible before you look for it; not aComboboxeither, because what comes back is not a value, it is something happening.keywordsis the prop that decides whether anyone opens it twice: words the query matches and that are never drawn, soRoll backis found by typingundoandDeploy productionby typingship.groupdraws a heading each time it changes.shortcutbinds the opening keystroke on the window and defaults toMod+K—Modbeing Command on a Mac and Control everywhere else, read from the same spellingShortcutdraws, so the label on the screen and the key that actually works cannot drift apart.shortcut={false}binds nothing, for an application that owns its own keyboard.Tour. A guided walk over a page that already exists — the three things a new reader has to be shown once, pointed at where they actually are. It isHowToStepsturned inside out: that component puts the instructions in the page and the reader follows them, this one leaves the page alone and stands over it. Each step names its target with a CSS selector rather than restating it, because what a tour is about is already on screen and a second copy inside the card is a second copy to keep in step.The dimming is one element with a hole in it — a box the size of the target carrying a shadow larger than any screen — rather than four rectangles around it, because the corners of a four-piece scrim never quite meet and the seams show the moment the dimming is anything but opaque. It never takes the pointer either, so the control being pointed at can still be used while the card is up, which is the whole difference between a tour and a sequence of dialogs. A step with no
targetis centred with nothing cut out, for a welcome card and a closing one.Transfer. Two lists and the arrows between them: everything that could be chosen on one side, everything that has been on the other. The shape for a choice that is long — the columns in a report, the permissions on a role, the people on a channel — where aComboboxwith forty chips in its field stops being readable and forty checkboxes give no answer to "what did I actually pick". Below about a dozen options, one of those two is the smaller component.Ticking a row is not choosing it: ticks say which rows the next press moves and
valuesays which side they are on, and keeping the two apart is what lets a filter hide a row without silently moving it. The order ofitemsis the order both lists show, so a row does not jump when it is sent across and back.Anchor. The list of headings on the page being read, with the one the reader is in marked. Real<a href="#…">s in a real<nav>: they jump to their headings with JavaScript off and they are in the link list, and the scroll tracking is added on top rather than being the thing that makes it work.The marked row is the last heading whose top has passed the line, which is the only rule that reads correctly going up as well as down, and the last heading wins once the scroll reaches the bottom — otherwise a final section with less content than a viewport is the one section that can never be marked. Nothing is marked while the reader is still above the first heading.
offsetclears a sticky header andcontainernames what scrolls when it is not the document, which is what aPageLayoutwithscroll="content"needs.DataList. A list of things and what they are called — a details panel, a summary of a record, the metadata under a heading. Real<dt>/<dd>pairs rather than a two-columnTable, and the difference is not cosmetic: a table is a grid of rows all of the same shape and a screen reader walks it as a grid, while this is a set of pairs and each one is read as "label, value". The label column sizes itself to the widest label, so every value in the list starts at the same place without anybody measuring.AvatarGroup. A stack of avatars, overlapping, with the ones that did not fit as a count.maxis how many are drawn andtotalhow many there are altogether, for a group handed only the first few. The first avatar is on top, because a stack read left to right is read front to back and the one the group is about should not be the one behind everything else.HoverCard. A card that opens when the pointer rests on something, holding a preview of what is on the other side — a person behind a mention, a repository behind a link, a deploy behind an id. It sits between the library's other two popups and is close to both: aTooltipis a label the pointer never reaches, aPopoverwas asked for by a press. This one is uninvited like the first and reachable like the second, so the pointer can cross into it and a link inside it can be followed.closeDelayis what makes the gap between the trigger and the card crossable.ScrollArea. A box with a scrollbar of its own, because the browser's is drawn by the operating system: seventeen pixels wide on one machine, overlaid and invisible on the next, and a different colour from the sheet it is cut into. NotScrollZone, which is a rail — a strip of items with buttons that step through them; this is the plain case of a box too small for what is in it, and underneath both are ordinary scroll containers, so the wheel, the trackpad, momentum and the keyboard are the browser's own.fadedims the content at each edge that has more beyond it and only there, so there is no fade at the top when you are at the top. It is a mask rather than a gradient painted over the content, because a gradient has to fade to a colour and a scroll area usually sits on a translucent acrylic sheet where there is no such colour.FormandFieldset. A<form>that knows which of its fields is wrong. On its own, a page ofTextFields validates one field at a time and a failed submit leaves the reader to find the red one; what this adds is the part that has to be owned above the fields — a submit collects every field's validity at once and moves focus to the first that failed, so the reader is taken to the problem rather than told there is one.errors, keyed by each field'sname, puts a server's answer back on the field it belongs to instead of in a banner, and clears it as soon as that field changes.It is not a form library. There is no schema, no resolver and no field array here — a project that wants those keeps them and hands the result to
errors, which is the seam this is built around.validationModedefaults toonSubmit, the only setting that does not tell somebody their email is wrong while they are still typing it.Fieldsetis the grouping and draws no surface, because a group of fields is a grouping and not a sheet — the sheet already exists, and this goes inside aCard. What it owns is the legend, the gap the controls stand at, and the one thing only a real<fieldset>can do:disabledreaches every control inside it, including ones a component three levels down rendered and never heard of it.Three new message namespaces in all eighteen languages —
anchorfor the table of contents'<nav>name,transferfor the two headings, the two buttons, the filter and the select-all, andcommandfor the palette's placeholder, its empty line and the name of a dialog that has no visible title — plussteps.skip, whichTourandHowToStepsshare. All four together move a twelve-component app with Korean registered from 69.2 kB to 69.6 kB.ScrollZonetakes the wheel, where it is asked to. A mouse has one wheel and it points down the page, which is the one axis a horizontal strip does not run along, sowheelturns a wheel rolled over the strip into travel along it. It is off by default, because a wheel taken from the page is the page's: a reader who meant to scroll past the shelf would be held by it instead. What it does take it gives back — at either end of the strip the wheel is the page's again — and a trackpad swiping sideways is left alone, since that already scrolls the strip and answering it here would move it twice as far as it was asked to.
Changed
A field with no
errorof its own now shows the message its validity already had.TextField,NumberField,OtpField,Select,Combobox,Checkbox,RadioGroupandSwitchrendered a message only when the caller passed one, so arequiredfield that failed the browser's own constraint went red and said nothing, and aForm'serrorsmarked a field invalid without ever drawing the sentence. Each of the eight now falls through to a plainField.Error, which renders the current validation message and nothing at all while the field is valid. An expliciterrorstill wins and still shows whenever the field is invalid. This is the tenth of a kilobyte in the twelve- and twenty-five-component rows above.Menuno longer passesmodalwhen it was not given one. Base UI's default is the sametrue, and it warns when the prop is set on a menu that turns out to be nested — which is every menu on aMenubarand every submenu. Not passing it is how the default stays a default; nothing about how a menu behaves has changed.NebaThresholdis in the shared vocabulary, insrc/types.tsbesideNebaSizeandNebaColor, becauseMeterandGaugeChartare the same reading in two shapes and a page carrying both must not disagree about where amber starts. It is the one place in the library where a semantic colour is computed.fill()— the{index} of {total}interpolation — moved fromHowToStepsintointernal/i18n.ts, now that a second component counts steps. The placeholders are part of the message format, and a language that orders them the other way round is exactly the case a second copy of that function would eventually get wrong.ScrollZone'sbuttonPlacementnow defaults toinline. The buttons stand beside the strip rather than over its ends, so an item is cut off at the button's edge instead of sliding beneath it, and the button is legible over the page rather than over whatever it landed on.overlayis still there and is still what a shelf of pictures wants, where the thing under a button is a picture that carries on. The type listsinlinefirst now, the way every other union in the file puts its default first.
Documentation
- Thirty-two component pages — sixteen components in both locales — with their props rows, sixty-one demos, sixteen cards in the component gallery and their blocks on the sample screen.
llms.txtand the README's component lists carry all sixteen. - The sample screen gains the two components that are only really visible in a page that already exists: a
Tourhung off the application header it points at, and aCommandPaletteopened by theMod+Krow the Keyboard card was already advertising. - One hundred and eighty new test cases across sixteen files, taking the suite to 2,228.
CLAUDE.mdrecords the new group memberships, the two internal modules that gained a second reader (button-group.tsforToggleGroup,menu.tsforMenubar), the newinternal/avatar-group.ts, and the refreshed bundle table.
1.8.1 (2026-08-29)
Neba could not be imported into a React Server Component, and nothing in this repository could have said so. React's react-server build does not export useState, useEffect, useRef, useContext or createContext at all, so the fifty-three modules here that called one were a TypeError in someone's Next.js app rather than a component that rendered badly — and every check stayed green, because nothing here renders on a server.
Fixed
- Every component is a client component and now says so.
'use client'on all ninety-nine component files, and on the seven modules underinternal/that hold a context or an effect. Importing one into a Server Component works, with no wrapper of your own and notranspilePackagesentry. The directive marks a boundary rather than a page: a Server Component that renders aButtonstays a Server Component, and only the components it renders reach the browser. What has not changed is the ordinary rule about that boundary — an event handler defined in a Server Component still cannot be passed across it. - All of them, and not only the ones that hold state today. Thirteen components would technically have survived a server render; eight of those are Base UI form controls that go inside a client boundary anyway, and the other five are one prop from failing, since
transitionis auseLayoutEffectandrenderis Base UI'suseRenderand nearly every component in the library already takes one or the other. A per-component answer would be a table that rots. It costs nothing measurable either: everynpm run sizescenario is unchanged to the byte, because a bundler hoists the directive rather than shipping it a hundred and six times. - The
nebabarrel, the component barrels andneba/localesare deliberately left unmarked, and each would break if they were not. A barrel only re-exports, so unmarked it belongs to whichever graph imports it and a Server Component importingnebareaches the client modules behind it rather than a boundary of its own.registerMessagesstays a plain function for the same shape of reason: marked, it would arrive in a consumer's server module as a client reference instead of a function and throw when called. terser.config.jsonsetscompress.directives: false, which is the half of this that would otherwise have failed silently.directivesremoves "redundant or non-standard" directives, and in a module — whereuse strictis implied — terser readsuse clientas both, stripping it out of all one hundred and six files without a word. The published package would have said nothing at all to Next.js while every check here still passed. It isoutput.preserve_annotations' twin: two settings, each keeping one thing terser eats on the way out.
Documentation
- Next.js and React Server Components, a new section in Getting started in both locales: what the directive does and does not do, why a handler written in a Server Component cannot be passed to a
Button, where the stylesheet is imported in an App Router project, and how to register a language from a module that is in the client graph.'use client'is a string at the top of a file, so Vite, webpack, Remix, Astro and plain React ignore it and nothing above changes what the package does there. The README carries the short form. test/package/resolution.test.tsgains four checks, since this is exactly the kind of invariant that is invisible here and expensive in someone else's project: that every component starts with the directive, that every module calling a client-only React API does too, that the barrels and the locales do not, and that the terser setting holding it all up is still there.CLAUDE.mdrecords the rule and its three deliberate exemptions.
1.8.0 (2026-08-29)
Everything Neba had until now went inside a page somebody else had already built. This release builds the page: a layout and the three regions it arranges, the mark that goes in the corner of it, and two components for the kind of page you would put in it — a code viewer and a step-by-step guide.
Eight components, nine exports, and not a byte on anyone who does not import them:
| What you import | 1.7.0 | 1.8.0 |
|---|---|---|
Button | 5.0 kB | 5.0 kB |
Chip | 3.0 kB | 3.0 kB |
LineChart | 11.0 kB | 11.0 kB |
| 12 components — a typical app | 67.0 kB | 67.0 kB |
| 25 components — a large one | 110.8 kB | 110.8 kB |
| a whole page shell | — | 28.1 kB |
CodeBlock | — | 4.8 kB |
| all exports (117 → 126) | 206.8 kB | 215.3 kB |
The five unchanged rows are the interesting ones: they are what says the new modules are still shaken out of a bundle that did not ask for them. neba/styles.css moves 17.5 kB → 19.7 kB gzipped, and 0.8 kB of that is CodeBlock's ported themes — see below.
Added
PageLayout, withHeader,FooterandSidebar. The skeleton a page is hung on, and what it is really for is the landmarks: a page assembled out of divs is one undifferentiated region to a screen reader and one undifferentiated blob to a crawler, while the same page built out of<header>,<aside>,<main>and<footer>has a table of contents.PageLayoutarranges the four, contributes the<main>and the skip link that jumps to it, and answers the questions the four cannot answer alone.headerSpanandfooterSpandecide which of the bar and the rail takes the top corner —fullis a website,contentis an application — and they are asked separately, because a dashboard with a full-height rail still usually wants its copyright line under the content rather than under the rail.scrolldecides whether the document scrolls or only the region between the bars.sidebarandendSidebarare two slots, for navigation down one side and a table of contents, an inspector or a filter panel down the other.The arrangement is flexbox and media queries on purpose: everything that decides where a column goes is stated in CSS, so the layout is right in the first frame the browser paints and right with JavaScript off. The only measurement is the header's and the footer's height, and only because a sidebar that holds its place has to start below a bar whose height nobody but the bar knows.
A
SidebarbelowcollapseBelowis aDrawer, not a second drawer written beside it — over a scrim, with a focus trap, an Escape and a way back to the trigger. Its children exist once either way, so nothing inside is in the document twice for a screen reader to read twice.SidebarTriggeris the hamburger that opens it and is hidden above the breakpoint by a class rather than being absent, or every phone would draw a header and then pop a button into it a moment later. A sidebar can also beresizable, dragged by its inner edge and reported throughonResizeEnd.HeaderandFooterare usable on their own, which is most of why they are separate components. A header takes three slots —brand, the middle, andactions— because the arrangement is fixed and what a caller wants to decide is what goes in each;align="center"centres the middle on the bar's own midline rather than in the space left over, so a logo one character longer does not move the navigation. Both takeposition,maxWidthon Container's ladder, and adivider.AppLogo. A product's mark, at a known size, that is never an empty box. Four things can be the mark and exactly one is at a time: markup handed tochildren, an image atsrc, the initials ofnameon a tile, or — with no tile to put them on — the name itself set as the logotype. That last one is the point: a product that has not drawn a logo yet still has a logo, and swapping it for the real file later is one prop.shapeis the decision an<img>cannot make for you: a mark drawn as a bare glyph and one drawn with its own background need opposite treatment and the file cannot say which it is.barekeeps the artwork's proportions and draws nothing behind it;appandcircleinset it into a tile.bareis the default because a logo file very often has the product's name set into it, which is also whynameis read out rather than drawn a second time.CodeBlock. A viewer for one line of code or a thousand. Everything it draws above the code is optional and off one prop each —toolbar,showLanguage,copyable,rawToggle,lineNumbers,startLine,prompt— because the same component has to be a bare snippet inside a sentence and the full transcript at the top of a README, and those are the same block with different things turned on rather than two components.Syntax highlighting is highlight.js, and every specifier that reaches it is behind an
import(): the core in one chunk, one chunk per grammar. So the block is 4.8 kB in the bundle a page downloads to draw its first frame,highlight={false}fetches none of the rest, and a block colouring TypeScript fetches about 11 kB more after the paint. Thirty-four languages come with it;registerLanguageisregisterMessages' arrangement for the other hundred and fifty.promptdraws a shell symbol —$,#,C:\>— that is never actually there: it is generated content, so it cannot be selected, cannot be found by find-in-page and never reaches the clipboard. A transcript stays a transcript and still pastes into a shell.highlightLinesmarks lines with a tinted row and a rule down the leading edge, taking a number, a string of lines and ranges ('1,4-9,12') or an array of either, counted the way the gutter counts. Ctrl/⌘ + A inside the focused block selects the code and nothing else.themeis the one colour decision in the library that does not follow the page. Code is read against a background chosen for code. Four themes are the library's own —dark(the default),light,autoandmono— and eight are ports kept at their published values:one-dark,dracula,monokai,nord,night-owl,gruvbox,githubandsolarized-light. A theme is a set of--n-code-*custom properties under a[data-code-theme]selector and nothing else, so the prop takes any string and a project that writes one in its own CSS has a theme with nothing to import and nothing to register.HowToSteps. A guide the reader walks through: numbered steps down one side, one step's instructions beside them, a way forward under those, and an end that says so. It isTimeline's interactive sibling and the two are deliberately not one component — a Timeline reports and nothing in it is pressed, while a HowToSteps asks.Every step's body is rendered into the same grid cell, with the ones not showing left in the document,
invisibleandinert, so the panel is as tall as the tallest step at every moment: moving from a one-line step to one with a code block in it does not resize the card the guide sits in, which on a page the reader has already scrolled moves everything under it. Nothing is remounted either, so a form halfway through a guide still holds what was typed into it.orientationruns the numbers down a column or across the top;maxHeightscrolls a long list and keeps the current row in view;dividerputs a hairline between the list and the body; a step'siconis drawn before its title over the body and never in the list, where the numbered disc already says which step it is.transitionis the entrance a step arrives with, from the library's usual motion vocabulary plus'none'— the one place that prop runs on something other than a mount, and inside the rule against moving a control for the same reason the rule exists: the effect is on the panel, and the buttons and rows that changed it hold still.Three new message namespaces in all eighteen languages —
layoutfor the skip link, the sidebar's name and the button that opens it,codefor the copy button and the raw toggle, andstepsfor the four buttons and the sentence at the end. All three together move a twelve-component app with Korean registered from 68.7 kB to 69.2 kB, which is the whole of what a new set of words costs a project that has registered a language.npm run sizegains a page shell scenario —PageLayoutwithHeader,Footer,Sidebar,SidebarTriggerandAppLogo— and prints each scenario's on-demand chunk total beside its entry. That second number is unbudgeted and exists so CodeBlock's grammars cannot quietly become the entry's problem: the day thatimport()turns static, 4.8 kB becomes 68.
Changed
highlight.jsis a second runtime dependency, alongside Base UI. OnlyCodeBlockreaches it, only through a dynamic import, and it therefore never lands in a bundle that did not ask for it. It is a real dependency rather than an optional peer because a specifier a bundler cannot resolve fails the whole build — Rollup walks and resolvesCodeBlock.jswhile it is still deciding whether to keep it — so an optional peer would breakimport { Button } from 'neba'for anyone who had not installed a highlighter.- A
Header's three slots are held apart by their own gap ladder, about twice the gap inside a slot. One ladder was doing both jobs, which put the first navigation link exactly as far from the logo as the logo sits from its own name, so the eye grouped the wrong things and the bar read as one undifferentiated row.
Fixed
- A press on a
FloatingBottomNavigationno longer moves the bar's height. The name on a floating destination collapsed in both axes, so an item without one was shorter than an item with one — and since a press animates two items at once, the tallest item in the row dipped to somewhere between the two heights and came back, taking the sheet with it. On a lozenge floating over the page that read as the whole bar wobbling. Only the column track travels now.
Documentation
- Seven component pages in both locales —
PageLayout,Header,FooterandSidebarunder Layout,AppLogoandCodeBlockunder Display,HowToStepsunder Surfaces — with their props rows, thirty-five demos, seven cards in the component gallery, and their blocks on the sample screen. - The sidebar and page-layout demos draw their navigation as plain text links rather than as a bordered
List, which is what a navigation rail is; the header demos' links lose their underline, which is the caseTextLink's three-wayunderlineprop exists for. CLAUDE.mdrecords the one deliberate exception to the stylesheet's marginal cost: CodeBlock's eight ported themes are 0.8 kB gzipped that everybody carries and only a CodeBlock user sees. Shipping them as tree-shakeable JS token objects was the alternative and was rejected, because it costs both the string prop and the consumer's own theme.
1.7.0 (2026-08-27)
A release about what lands in your bundle. Nothing about how a component looks or behaves has changed; what changed is how much of the library you have to take to get one of them.
Measured with a real bundler rather than reported from an unpackedSize — rollup and terser, react external, judged on gzip:
| What you import | 1.6.0 | 1.7.0 |
|---|---|---|
Chip | 14.5 kB | 3.0 kB |
LineChart | 22.8 kB | 11.0 kB |
| 12 components — a typical app | 79.8 kB | 67.0 kB |
| 25 components — a large one | 124.4 kB | 110.8 kB |
| all 117 exports | 218.7 kB | 206.8 kB |
The last row is the least interesting one, and that is the point: taking the whole library is barely cheaper, because nothing was removed. What changed is the price of taking a part — and the reason Chip used to cost fourteen kilobytes was never Chip.
Changed
Languages are registered rather than shipped. Neba speaks English out of the box and the other eighteen languages are now modules you turn on:
tsximport { registerMessages, ko } from 'neba/locales'; registerMessages('ko', ko);Call it once at module scope, before your first render, for each language you support; then a
localeprop translates exactly as it did.This is a breaking change in a minor release, which is deliberate given how few projects are on Neba today, and it is the only one here. A project that passes
localeand registers nothing will see English where it used to see a translation — the strings still resolve, they just resolve to the fallback. If you pass alocaleanywhere, add the two lines above and you are done. Everything else is unchanged: tags are still matched by script, then region, then language, so registeringkoanswersko-KRandzhHansregistered aszh-hansanswerszh-CNand a barezh; a language still fills in only what it has and English answers for the rest; and every string a component invents still has a prop that overrides it.The reason is arithmetic. Eighteen languages of sixteen namespaces was one object literal, and a bundler cannot drop a key out of an object literal — so a
Chipthat wanted the word "Remove" carried the ColorPicker's colour names and the Table's column labels in eighteen languages. Registered, a language costs about 1.7 kB gzipped and you pay for the ones you name.severityIconsis nowseverityIcon(color)internally, so a component that draws one severity mark carries one, and no React elements are built at import time for a page that may draw none.
Added
- Every component is its own entry point, named after its folder:
import { Button } from 'neba/button',import { TextField } from 'neba/text-field'. The bundle is the same as the barrel's —import { Button } from 'neba'already tree-shook correctly — but the barrel makes a bundler parse two hundred modules to keep five, and the subpath makes it parse five. It is also the escape hatch if your bundler ignoressideEffects. neba/localesfor the barrel andneba/locales/kofor one language, plus theNebaLocaletype for a translation of your own.neba/package.jsonis exported, which some tooling asks for.npm run size— a bundle-size budget. Seven scenarios, from one component to all of them, bundled againstdist/and checked against the numbers committed inscripts/bundle-budget.json. It runs as its own CI job, so a change that quietly stops something being tree-shakeable fails a pull request instead of shipping.npm run size:updaterecords a new budget when the growth is real and wanted.
Fixed
- The package can be imported by Node, and by TypeScript on
moduleResolution: node16. Emitted ESM used extensionless relative imports (export * from './types'), which Node's resolver rejects outright and which TypeScript onnode16ornodenextreported as "Module 'neba' has no exported member" — for every component at once. Anything using a bundler was unaffected, which is why it went unnoticed. Every relative specifier now carries its.js. - The published files now carry
@__PURE__annotations, so a bundler can drop the parts of a multi-component module you did not import. They are written intodist/during the build and terser is told to keep them, which it does not do by default — until now the annotations reached the consumer's bundler stripped, and it keptTabandTabPanelfor anyone who imported onlyTabs.Tabson its own is 23% smaller.
Documentation
- The README gains a Languages section and the subpath import form;
CLAUDE.mdgains a Packaging, bundle size and tree-shaking section with the measurements, the five invariants that hold them in place, and the list of things measured and rejected so they are not tried again — minifier tuning, per-component stylesheets, and dropping Tailwind's@propertyfallback. test/package/resolution.test.tsandtest/locales/register.test.tsxare new. Neither renders a component: the first checks the wiring betweensrc/,dist/andpackage.jsonthat no render test ortsc --noEmitcan see, and the second checks the registration contract.
1.6.0 (2026-08-24)
Added
ScrollZone. A strip of anything laid out in one direction and scrolled in it — a rail of cards, a row of chips, a column of rows.orientationdecides which way it runs,lineshow many rows it fills before starting a new column, so one scroll can hold twice as much in the same width, andspacingis the gutter on the same Tailwind scaleGridContaineruses. The mechanism underneath is an ordinary scroll container, which is the whole design: touch, the wheel, the scrollbar and the arrow keys are the browser's own, nothing is transformed, and the strip runs the other way under RTL without being told.- What a press of its buttons does is a prop.
modeisitem(withstepfor more than one at a time),page, orhold— which scrolls atspeedpixels a second for as long as the button is down, and falls back to one item on a press too short to be a hold, so a quick tap is never a dead press.buttonsdraws them only when there is somewhere to go, always (with the useless one disabled), or never.buttonPlacementdecides whether they sit over the strip or beside it:inlinestops the scroller where the button starts, so an item is cut off at its edge rather than sliding under it, and the lane is kept even while that button has nowhere to go.dragadds a mouse and pen drag to the finger's;snapbrings the nearest child to the leading edge whenever the scrolling stops. FloatingBottomNavigation.BottomNavigationlifted off the page: the same<nav>, the samearia-current, and the sameBottomNavigationItemchildren, which is why the item's context moved tointernal/— two bars provide it and one item reads it. What differs is everything that follows fromoffset, the gap it floats above the bottom edge: the sheet is a stadium rather than a bar with two corners, it is only as wide as its destinations, it carries a shadow,positiongainsabsolutefor a bar that belongs to a region rather than to the window, andlabelsdefaults toselected, because five drawn names would stretch it back into a bar.- Its highlight belongs to the bar rather than to the destination that is current, which is what gives it a position to travel: it is measured off whichever item carries
aria-currentand animates itsleft,top,widthandheightto the next one, transforming nothing. A name the bar is not drawing is collapsed rather than clipped — the box it sits in runs between0frand1frin both axes — so pressing a destination re-shapes the bar around it: the name grows, its neighbours move over, and the highlight slides under it on one clock. WindowPane. Anything at all, drawn the way an operating system draws a window. It is not a real window and does not pretend to be one — there is no desktop and no z-order — but the frame behaves: the title bar drags, all four edges and all four corners resize, and the three buttons are real buttons with real names, so a screenshot, a feature demo or a piece of a landing page can be shown as the thing it will be rather than as a picture of it.osis eight systems, a version being its own entry wherever the title bar is what changed.macosandmacosx(Aqua — a short striped bar, glossy lights, a bold embossed title);windows11(rounded corners, bar and body one Mica sheet),windows10(square, white, ruled off from the body),windows8(flat, with a band of colour around the whole window),windows7(Aero — a sheet of glass with the content sunk into it and the page blurred through the band as well as the caption) andwindowsxp(Luna — the glossy blue caption curve and a band of the same blue down the sides and along the bottom); andlinux, a GNOME header bar. The older systems paint their own chrome rather than the page's, so Luna stays blue and Aqua stays grey on a page switched to dark — the choiceMockup's finishes already make. Nothing here is a copy of anything: a minimize is a line, a maximize is a box, a close is a cross, and no mark, wordmark or icon belonging to anyone else is drawn.controlsistrue,falseor exactly the buttons named, in the system's order rather than the array's.open,minimizedandmaximizedare each a controlled/uncontrolled pair: closing an uncontrolled window renders nothing, minimizing rolls it up to its title bar — a page has no dock to send it to — and maximizing fills whatever is holding the window.draggableandresizablereport throughonOffsetChangeandonResize, withminWidth/minHeightbounding them.activelooks after itself. Left out, a window is in front until anotherWindowPaneon the page is pressed or takes the focus; a press on the page around the windows changes nothing, because a paragraph is not a desktop. Being in front is drawn the way each system draws it — coloured traffic lights, an accent title bar and border on Windows 10, a tinted header bar on GNOME — and on all of them it is one step more shadow than the windows behind.accentdyes the title bar (and, on the systems with one, the band) with the colour family;transparencylets the page through the chrome and turns the acrylic on with it, never touching the content on top.- Maximizing, restoring and rolling up are journeys rather than cuts.
left,top,widthandheightare what move — never a transform, so no glyph in the window is resampled on the way — and a window that was never given aheightis measured and pinned for one frame, becauseautois not a length a transition can start from. A rolled-up window keeps its body in the tree,inertand clipped; a closed one fades before it goes; a reader who has asked for reduced motion gets every one of them instantly. - Two new namespaces in
src/internal/i18n.ts, translated into all eighteen locales:scrollfor the two buttons that are an arrow and nothing else, andwindowfor minimize, maximize, restore and the corner a keyboard resizes with. The × isaction.close, which every other close button in the library already reads. --neba-duration-window— 240ms, longer than a control's 160ms because what is travelling is the whole sheet rather than a colour on one. Nothing but a window may use it.
Documentation
- Three component pages in both locales —
ScrollZoneunder Layout,FloatingBottomNavigationunder Inputs,WindowPaneunder Surfaces — with their props rows, fifteen demos, three cards in the component gallery, three new blocks on the sample screen, and their entries inllms.txtand the README. - The props table's name column wraps at spaces and never inside a word, which is the arrangement the type column already made with the pipes in its unions. One row of five names had been setting the width of a forty-row table; the five are their own rows now, so a reader looking for
closeLabelfinds a row calledcloseLabel.
1.5.0 (2026-08-12)
Added
- Charts. Five components on one engine:
Sparkline,LineChart,AreaChart,BarChartandPieChart. They take their data the wayTabledoes —series(or, for a pie,data) as a prop rather than markup — and they take it in the same shape as each other, so a dashboard tile can be switched from one chart to another without rewriting what feeds it.Statisticjoins them in a new Charts group in the docs. - A
nullis a gap, not a zero, on every one of them. A line breaks at it, an area breaks with it, a bar is not drawn and the table cell is empty;connectNullsbridges it for the case where the gap is an artefact of collection rather than a month in which nothing happened. A chart that renders missing data as zero reports an outage as a collapse. - A series' colour follows its place in the array it was passed, never its position among the visible ones — so filtering a legend cannot repaint the survivors, and a reader who learned that Europe is blue keeps that.
series.coloroverrides the slot with aNebaColorfamily or any CSS colour, and a point's owncoloroverrides that for one mark. LineCharttakescurve(linear, a monotonesmooththat will not dip below a value both its neighbours are above, orstep),markers,gradient,connectNulls,valueLabelsandstacked. Its value axis crops to the data, because a line encodes a position and cropping moves every point by the same amount.AreaCharttakes the same three curves plusstacked—truefor totals,'full'for a chart about the mix rather than the size, where the axis becomes a percentage and the tooltip and the table keep the caller's own number. Its axis keeps zero, because there the fill's thickness is the magnitude. Unstacked bands are a wash that fades downward so two of them overlapping stay readable; stacked bands are a flat tint parted by two pixels of the surface rather than by a stroke.BarCharttakesorientation—horizontalis the right answer whenever the category names are words — plusstacked(true/'full'),rounded,barSizeandvalueLabels. Corners are cut off the data end only; a rounded foot makes the axis look scalloped. Bars grow from where zero is rather than from the bottom of the plot, so a negative bar starts on the same line as its neighbours.PieCharttakesshape—pie,donutor asemicircle — pluscenterfor what goes in the hole, andvalueLabelsfor each slice's share, drawn only where the text fits with room on both sides and dropped rather than clipped where it does not.Sparklineis not a small chart: no axes, no grid, no legend, and no numbers, because every number it could label is one the sentence around it already has.shapeis a line, an area or bars;endDot,baseline,minandmaxare the rest of it. It fills itself with its own range, which is what makes it legible at twenty pixels tall — and why two of them are only comparable when both are given the sameminandmax.- A hidden table under every chart, always. The drawing is
aria-hiddenand the data is rendered as a visually clipped<table>captioned withlabel, so no value is reachable only by pointer. The plot itself is focusable:←/→step the crosshair between categories,Home/Endjump to the ends,Escapeclears it, and the tooltip is a live region. The legend is a list ofaria-pressedbuttons, so which series are drawn is stated rather than implied by colour. - Eight new colour tokens,
--neba-chart-1…--neba-chart-8, per theme — and the one place in the library where a colour is not a semantic role, because a series is an entity and nothing about it means success or danger. They were solved rather than picked: every step inside the OKLCH lightness band, chroma above the floor where a hue stops carrying identity, every adjacent pair at least ΔE 8 apart in OKLab under simulated protanopia and deuteranopia (measured at 13.6 light and 14.5 dark), and every slot above 4:1 on white and 5.3:1 on the dark sheet. Dark is re-solved against the dark surface rather than lightened from the light values. Slots are handed out in order and never cycled: a ninth series is an "Other" row or a second chart, not a ninth hue. --neba-chart-grid,--neba-chart-axis,--neba-chart-baselineand--neba-chart-gap— the chart's chrome, derived from--neba-borderand--neba-surfaceso a chart's rules and a Card's dividers are one family of line. Gridlines are solid hairlines: a dashed grid says "projection" when all it is is a grid.src/types.tsgains the vocabulary the charts share —NebaChartSeries,NebaChartDatum,NebaChartPoint,NebaChartCategory,NebaChartAxis,NebaChartLegend,NebaChartTooltip,NebaChartCurve,NebaChartValueLabels— for the reasonNebaSizeis there: aserieshanded to a LineChart has to be the one a BarChart takes.Collapsible. One section that folds, standing on its own — what anAccordionis a set of, for a "Show more" on a form or the detail under a row.title,subtitle,startIconandactionbuild the header the wayAccordionItemdoes, withactionoutside the trigger so a header that both folds and holds a switch has two things to press rather than one nested in the other;triggerreplaces the header entirely and the element passed becomes the trigger, handed the click,aria-expandedand thearia-controls.keepMountedandhiddenUntilFounddecide whether a closed panel is in the DOM, the second of them so the browser's own page search can open it.Rating. A score as a row of stars, and two components in one skin. Choosable, it is arole="radiogroup"of real<input type="radio">s hidden under the half-stars they stand for — one tab stop for the row, arrow keys within it,aria-checkedon the one that is taken and a value in a form submission, none of which a row of<button>s would have.readOnlyrenders no inputs at all and leaves onerole="img"carrying the score as a sentence, which is why it is the one read-only in the library that does not drain the saturation: it is not a control being held still, it is the number itself.precisionbounds what can be picked and never what is drawn, so avalueof4.3stays 4.3 — an average is not a choice, and rounding it would report a different number from the one it was handed. The fraction is a filled star laid over an empty one and clipped from the inline start, so nothing is scaled and a half star fills from the right under RTL on its own.BottomNavigationandBottomNavigationItem. An app's main destinations, held against the bottom edge of the window —positiondefaults tofixedhere, against thestaticeverything else defaults to, because that is what the component is. It is a<nav>of ordinary buttons (or<a>s, given anhref) witharia-current="page"on the one you are on, and deliberately not arole="tablist": a tab list promises one tab stop for the set and arrow keys within it, and a bottom navigation changes the page rather than which panel of one is showing.labelsdraws every name, only the current one, or none — and a name it does not draw stays in the document, because a glyph on its own has no accessible name at all.safeAreaholds the row clear of a phone's home indicator while the sheet still reaches the bottom of the screen.FloatingActionButtonandFloatingAction. The one action a screen is about, floating over it. The button is aButtonunchanged — same variants, same elevation ladder, same pointer light — started one step up the size ladder atlg, because this is the control that has to be found and hit with a thumb without being looked at.extendedwriteslabelbeside the glyph, which is also its accessible name, so the word drawn and the word read can never differ.positionaddsabsoluteto the three CSS valuesNebaPositionhas, which is what pins one inside a card or aMockuprather than to the window;cornerandoffsetplace it. GivenFloatingActionchildren it becomes a dial:aria-expandedandaria-controlsrather than arole="menu"it would not honour, the actions as ordinary buttons next in the tab order, Escape closing it and handing the focus back, and a press outside putting it away.
Documentation
- Five component pages in both locales —
Sparkline,LineChart,AreaChart,BarChart,PieChart— with the data model written out once on the LineChart page and cross-referenced from the rest, their props rows (including tables forNebaChartAxis,NebaChartLegendandNebaChartTooltip), sixteen demos, a Charts group in the component gallery, three charts and a row of sparklines on the sample screen, and their entries inllms.txt. Statisticmoved from Display to Charts, in both locales and in every index that names it.- Four component pages in both locales —
Collapsibleunder Surfaces,Rating,BottomNavigationandFloatingActionButtonunder Inputs — with their props rows, twenty-one demos, four cards in the component gallery, two new blocks on the sample screen, and their entries inllms.txtand the README. - A
ratingnamespace insrc/internal/i18n.ts, translated into all eighteen locales: the group's name, the sentence one star is read out as, and what an unrated control says. The sentence carries both numbers rather than counting stars, because a count of stars is a plural in most languages and a fraction in none of them.
1.4.0 (2026-08-06)
Added
Empty— what stands where content would have been, in four slots:icon,title,childrenandaction. It is the other half ofSkeleton: one is the shape of something on its way, the other is the shape of something that is not coming, and a list showing neither has a blank rectangle where its answer should be.- The headline is defaulted rather than required, because the version that says nothing useful is the version that gets shipped.
titlefalls back to thelocale's way of saying that there is nothing here — a newemptynamespace in the message table, translated into all eighteen languages the library already speaks — andtitle={false}drops it for a state that is a glyph and a sentence.icondefaults to an empty tray and takes any node, so an illustration or another set's glyph goes in without a wrapper;actionholds the way out, and several of them wrap together in one row. variantdefaults totexton this component and on no other: an empty state is nearly always already inside a Card, a Table or a panel, and a second rectangle inside the first is one rectangle too many.outlineandsolidare the undyed container sheet, socolorreaches the hairline and the ring and stops — an empty state arriving in the accent colour is making a claim about content that does not exist. The root is arole="status"live region, which is what lets a list that empties under the reader say so;role={undefined}turns that off for a state that is part of the page on arrival.Mockup— a device with a screen you can put anything on.deviceis the one prop with no default and picks the machine: amobile, atablet, or adesktopthat is either amonitoron a stand or alaptopon a base.osdraws that system's own chrome — a menu bar and a floating dock, a centred taskbar, a top bar with a dock down the leading edge, a status bar with a home indicator or three navigation glyphs — andsystemUi={false}takes all of it away.bezelruns fromnone(no hardware at all, just the glass) throughthinandstandardtothick, which is an older device with a forehead and a chin rather than a wider frame;finishisgraphite,silverorwhite;notchis adynamic-island, anotch, apunch-holeornone, defaulting to whatever the device would really have.orientationturns a handheld, and the screen, the bezel and the cut-out turn with it.- The screen is a viewport at the device's real resolution, not a picture scaled down.
sizeis a five-step ladder of genuine resolutions per device — a phone from 320 to 430 CSS pixels wide, a desktop from 1024 to 1920 — andresolutiontakes a{ width, height }pair for anything else. The whole device is then scaled once to whateverwidth/heightcome to on the page, so the same component laid out inside a phone and inside a desktop wraps differently in each. The screen is also a container namedneba-screen, so content can answer to the device with a container query rather than to the window. scrolllets content taller than the screen scroll instead of being clipped;wallpapertakes any CSSbackgroundfor what sits behind it;timeis the clock, and the only text the chrome draws — every menu title, dock icon and tray glyph is an abstract shape, so there is nothing to translate and no other party's marks in the library. Every part of the device isaria-hidden, which leaves a screen reader withchildrenand nothing else.DataTable— a table for a lot of rows.Tabledraws a grid; this one is a place to work, and it takes the same two props to get there:headersanditems. It is compact by default (sm,compact) with its own row ladder a step below the rest of the library,stripedtakestrue,'odd'or'even', and the parity is counted over the whole set so it does not change as the rows are scrolled or sorted.- Only the rows on screen are in the DOM, and
heightis what turns that on. Every row isrowHeighttall — defaulted off thesize/densityladder — so the offset of a row is its index times a constant and nothing has to be measured on a scroll frame; the rows that are left out stand up as two spacer rows, which is what keeps the scrollbar honest. Without aheightor amaxHeightthere is nothing to measure against and every row is rendered, whatevervirtualsays;virtual={false}asks for that on purpose, for a table small enough that find-in-page matters more than the DOM count. Cells truncate rather than wrap, which is the price of the arithmetic. - Selecting is the file manager's, not the form's. A click chooses a row and drops the rest, Ctrl/⌘ adds one, Shift takes the run between, a drag takes the run under the pointer and scrolls when it reaches an edge, and the arrow keys do all three with the same modifiers — plus Home, End, PageUp, PageDown, Space, Ctrl+A, Esc and Enter.
selectionModeisnone,singleormultiple;checkboxesadds a tick column and is deliberately not the default, because a column of checkboxes says the task is choosing and on most tables it is not. With a selection mode the table is agridwith one tab stop andaria-activedescendant, since a virtual row cannot hold the focus. sortablemakes the headings pressable and they cycle ascending → descending → unsorted, because the order the rows arrived in is a state nothing else can get back to.sortMode="multiple"lets a Shift-click add a second key rather than replace the first.resizableputs a handle on each boundary; the first drag freezes every column at the width the browser had given it, so pulling one moves one, and a double-click restores it. Adjacent columns sharing agroupstring merge under one heading in a second header row.paging="pages"swaps the scroll for a footer holding the range, the number of chosen rows, a page-sizeSelectand aPagination;footershows that bar on its own, so a scrolling table can have the count without the pages.searchmatches every column that has not setsearchable: false, case- and accent-insensitively, andsearchabledraws the field withtoolbarfilling the rest of its bar.manualhands any of'sort','filter'and'pages'back to the caller — withrowCountfor the total — so a table whose rows come from a server is the same component rather than a second one.- The i18n table gains a
tablenamespace — the search field's placeholder, the two ticks' labels, the page-size label, and the footer's count and selection sentences — in all eighteen languages. The two that carry numbers are whole templates with{start},{end},{total}and{count}in them rather than fragments, because what differs between languages is the order the numbers appear in.
Fixed
- Forcing a theme on a nested element now works in both directions.
.dark/[data-theme='dark']on an element that is not the document root was already documented, but the light values were declared on:rootalone, so.light/[data-theme='light']inside a dark page had nothing to switch back to. The light block now carries all three selectors. --neba-plate-solid,--neba-plate-glassand the--neba-shadow-1…--neba-shadow-4ladder moved into the derived block. Each is a base token spread into a shadow list, and declared only on:rootthey froze to the light hairline and the light ambient inside any theme root that was not the document root.
Documentation
- Every live preview carries a theme switch in its top corner, so a component can be read in the theme the page is not in without taking the whole site with it. Untouched previews still follow the site switch, and a preview flipped back to the page's own theme rejoins it.
- An
Emptypage in both locales with eight examples, its props rows, its demos, a card in the component gallery, a place on the sample screen beside the placeholder it is the other half of, and its entry inllms.txt. - A
Mockuppage in both locales with eleven examples, its props rows, its demos, a card in the component gallery, a place on the sample screen and its entry inllms.txt. - A
DataTablepage in both locales with seven examples, two props tables, its demos, a card in the component gallery, twelve thousand rows on the sample screen and its entry inllms.txt. - The nav bar and the sidebar no longer hang off the edges between 1440px and 1600px. The site raises
--vp-layout-max-widthto 1600px, and the default theme centres the shell on the article by taking half of what the page has left over — an expression it writes out five times without a floor, because at its own 1440px it cannot come out below zero. At ours it can: the social icons sat 8px outside the viewport and the sidebar climbed over the article's left margin. All five now read one floored--neba-gutter, which reduces every one of them to the gutter plus nothing, the page margin, or the sidebar's width. npm run docs:devstarts in about four seconds instead of fourteen. All threedocs:*scripts rannpm run buildfirst — ten seconds offormat:fix,tscand terser that nothing then looked at, since the docs resolvenebatosrc/index.tsand importsrc/styles.cssdirectly. Prettier now runs where it always did as part ofnpm run build, and CI still checks it.
1.3.0 (2026-08-05)
Added
- A sixth group, Transitions: eleven
Animate*wrappers that make anything move.AnimateFade,AnimateGrow,AnimateZoom,AnimateSlide,AnimateRotateandAnimateBlinkare the six named effects;AnimateAppear,AnimateTyping,AnimateLighting,AnimateMarqueeandAnimateHeadlineare the five that have to understand what their children are. All eleven take the same settings —durationanddelayin milliseconds,easing,repeat(a count or'infinite'),alternate,paused, andtrigger:mount,visible(withonceandthreshold),hover, ormanualdriven byplay. Every one of them is switched off entirely by aprefers-reduced-motionpreference, so none of them is ever the only thing carrying a message. - A
transitionprop on the components that display something —Box,Card,Statistic,Alert,Chip,Avatar,Icon,TypographyandBlockquote.transition="fade"is an entrance run once on mount, and the object form takes the details:{ type: 'slide', from: 'left', duration: 500 }. It is offered on no component that is pressed, because a control that moves under the pointer aiming at it is the one thing the design language rules out. Anything past a mount — a replay, a scroll trigger, a hover — is anAnimate*component, and any component can be wrapped in one. ColorPicker— a colour chosen by eye: a saturation square, a hue rail, an optional opacity rail, a field for typing a value in, and a grid of swatches.formatdecides whether the value comes back as hex,rgb()orhsl();alphaadds the fourth channel;swatchesreplaces the built-in set with the colours a product actually uses;inlinedraws the panel into the page instead of into a popup. It reads hex in all four lengths,rgb()/rgba()andhsl()/hsla()in both syntaxes, and it adds no dependency — the conversions are a hundred lines of arithmetic ininternal/color.ts.- The i18n table gains a
colornamespace, so the picker's square, rails, field and swatch grid — none of which have any text on them — are named in all nineteen languages.localepicks the language andlabelsoverrides any one of them.
Documentation
- Pages for
ColorPickerand the elevenAnimate*components in both locales, their props rows, their demos, cards in the component gallery, a place on the sample screen, and their entries inllms.txt. - Prop conventions gains a Motion section, which is where the shared animation vocabulary and the rule about which components take
transitionare written down.
1.2.0 (2026-08-01)
Added
- Eight components, bringing the library to fifty-seven:
Avatar,Breadcrumb(withBreadcrumbItem),ChatBubble,OtpField,Panes(withPane),Spoiler,TextLinkandTreeView(withTreeItem).Panesopens a fifth group, Layout, alongsideContainerandGrid. - The library speaks nineteen languages on its own behalf. Almost nothing in Neba writes text a reader sees — a Button says whatever it was handed — but a few components have to invent a string because there is nowhere else for it to come from: the sentence read out after a link that opens a new tab, the label on the button that uncovers a
Spoiler, the word under a chat message that says it was read. Those are now one table rather than eight English defaults.TextLink,SpoilerandChatBubbletake alocale, and every string still has an override prop of its own, so an unsupported language is never a dead end. A tag resolves by script, then by region, then by language (zh-Hant-TW→zh-hant,pt-BR→pt), and a translation that fills in part of the table falls back to English one namespace at a time rather than leaving blanks. Avatar— an image that falls back to initials, to a glyph, or to a silhouette, so the slot is never an empty box.shapeiscircleorsquare,delayholds the fallback back long enough for a cached image not to flash it, andonLoadingStatusChangereportsidle/loading/loaded/error.Breadcrumb— a trail that collapses in the middle when it is too long:maxItems,itemsBeforeCollapseanditemsAfterCollapsedecide where, andexpandablemakes the ellipsis a button that opens the rest in place.separatortakeschevron,arrow,slash,dotor a node of your own.ChatBubble— one message in a conversation. The avatar, the name, the time, the delivery mark, the media and the link card are each drawn only when given something, so the same component is a bare bubble or a full row.statusrunssending→sent→delivered→read, plusfailed;typingdraws three dots that change colour rather than bounce.OtpField— a one-time code as one field per character.length,charset(numeric,alpha,alphanumeric,any),mask, andgroupSizewith aseparatorfor codes written in blocks.onCompletefires when the last box is filled,autoSubmitsubmits the form it is in, andonValueInvalidreports what was rejected.Panes— regions with a draggable bar between each pair, horizontal or vertical. EachPanetakesdefaultSize,minSizeandmaxSizeas a number of pixels or any CSS length;resizableturns the bars off, andonResize/onResizeEndreport the split.Spoiler— content covered by a blur rather than removed, so a reader can see that something is there without reading it by accident. Controlled withrevealed/defaultRevealed/onRevealedChange;reversibleallows covering it again,maxHeightclamps it to a fade-out instead, andbluris the radius.TextLink— a link with no surface of its own.underlineisalways,hoverornone,newTabadds the mark and the sentence a screen reader needs,iconoverrides or drops that mark, andrenderswaps in a router's own link component.TreeView— a tree with roving focus, controlledexpandedandselected, andmultiplefor checkbox-style selection.linesdraws the guides asnone,simpleorfolder— the rail runs past an expanded subtree to the next sibling and stops halfway down the last child, the way a file manager draws it, and mirrors under RTL without a second rule.
Changed
- The Examples section is four pages, not one.
/examples/no longer resolves to a page — the overview moved to/examples/overviewand three concept screens joined it. Any link you have to/examples/needs updating. - The stylesheet carries a
.neba-linkrule, written as.neba-link.neba-linkso that a host's.prose aor.vp-doc acannot outrank the link's underline thickness, offset and colour. Likeneba-portal, the class is a hook you can exempt from your own typography rules.
Fixed
- The date formatter cache joined a locale and its options with a space, which is a character both halves of the key can contain. Two different requests could land on one entry and the second would be formatted with the first's options. The separator is now one that cannot appear in either.
Documentation
- Three concept screens, each a whole fictional product page built only out of the library: a landing page, an admin dashboard and a sign-up flow.
- The home page now says what the library is rather than how it is drawn — what ships, what is tested, and what an install actually gets you.
- Every page carries its own description. A page's lede is read out of the source and becomes its
<meta name="description">, so pages no longer share one sentence between them. Canonical URLs, anhreflangpair per locale with anx-default, Open Graph and Twitter cards, JSON-LD on the home page, and arobots.txtnaming the sitemap are generated in the build. - A preview mounts when it is scrolled into view, not when the page loads. A component page holds a dozen of them, and mounting all at once put the one being read behind chunks for previews far below the fold. The empty box reserves its height before and after, so nothing jumps.
- New icons and social image at 128 and 256 px, an
apple-touch-icon, and atheme-color. - Pages for the eight new components in both locales, their props rows, their demos, and their entries in
llms.txt.
1.1.0 (2026-07-29)
Setup
neba/styles.cssnow ships compiled. It carries the design tokens, the real rules for every utility class the components use, and a small reset — so the whole setup isnpm install nebaand one@import. Tailwind is no longer a requirement on your side; it builds this package and stays a devDependency of it. About 13 kB gzipped.New export:
neba/tailwind.css. The token sheet, for a project that already runs Tailwind v4 — it registers the package as a Tailwind source, so your own build generates the components' utilities in the same pass as your app's and aclassNameyou pass to a component sorts correctly against the component's own classes. If you were on the previous two-line setup, this is the line to switch to:css@import 'tailwindcss'; @import 'neba/tailwind.css'; /* was: neba/styles.css */The bundled reset is Tailwind's Preflight cut down to what the components actually need —
box-sizing, font inheritance on form controls, list markers off. It does not touch the typography of your paragraphs, headings or links, and every rule is wrapped in:where(), so a single type selector of your own beats it whatever the import order. It is inneba/styles.cssonly; the Tailwind path has Preflight already.reactandreact-domare now declared aspeerDependencies(^18.0.0 || ^19.0.0) rather than only as devDependencies, so the requirement is stated where a package manager can check it.@types/reactis an optional peer for TypeScript consumers.
Added
- Eighteen components:
Blockquote,Carousel,Container,DatePicker,DateRangePicker,DateTimePicker,Grid,GridContainer,Highlight,Icon,IconButton,Pill,SegmentedButton(withSegment),Shortcut,Statistic,TimePicker,Timeline(withTimelineItem) andToolbar. - Shared types for the new layout and date work:
NebaPosition,NebaBreakpoint,NebaResponsive<T>,NebaWeekday,NebaJustifyContent,NebaAlignItemsandNebaAlignSelf. Dividertakes athickness, as a number of pixels or any CSS length.Pilltakeselevation,startIcon/endIcon,title/description/details,expandedandposition.llms.txt, for agents reading the documentation site.
Changed
Select,DatePickerandDateRangePickerhold their trigger at the width of the longest thing it could say. Choosing a shorter option no longer shrinks the field out from under the pointer that just used it.CheckboxandRadioGroupdrop the white plate highlight. A 1px hairline is light on a cut edge at 32px and a bevel at 18px; the acrylic surface stays, only the highlight goes.- A highlighted row in a
SelectorComboboxpopup now takes the accent text colour as well as the soft background.
Fixed
SelectandComboboxpopups render through a portal, outside the element their--n-*slots were declared on, which left everyvar()in them with nothing to resolve to: acurrentColorhairline instead of the family's, a transparent surface, and a highlighted row that did not light at all. The slots are now set on the popup itself.- A field at the top or bottom of a scrolling
Dialogbody had its focus ring sliced off by the scroll container. The body withoutdividersnow reserves the 4px the ring is drawn in and gives the space straight back, so nothing on the sheet moves. Paginationkeyed its buttons by page number, so recentring the window moved the DOM nodes and the button under the pointer became a different element — its hover bloom faded out while a fresh neighbour's faded in from a centre it had no pointer position for. The row is keyed by slot now.Combobox's input stretches to the height of the row it sits on rather than to a fixed1lh, which put the placeholder a pixel or two above the chips beside it.
Documentation
- The sidebar is four sections — Guide, Components, Design and Discover more — with the component groups kept as headings inside Components, and the index page as an entry of its own rather than as the heading's link.
- The design pages moved from
guide/todesign/. - This changelog is published at /changelog in both locales.
1.0.0 (2026-07-26)
- First release
0.0.1 (2025-12-08 / Alpha)
- Alpha release (Not tested. Do not use production.)