Changelog
This package's history. The Flutter package keeps its own at
packages/flutter/CHANGELOG.md, because the two version independently.
vNext (2026--)
Fixed
PlChipkeeps 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.PlSpoilerno longer changes height when it is uncovered. The cover is a line of explanation and a button, so it is routinely taller than the text it covers — and it was taken out of the layout on reveal, which collapsed the sheet to its content and pushed the whole page below it up. Covering it again pushed everything back down. The cover now keeps its place and is held hidden andinert, exactly as thereversiblehide row already was, so the sheet measures the same in both states. AmaxHeightclamp is still released on reveal, which is the one thing that may resize it.A justified
PlGalleryno longer blows up its last row. Every tile is grown in proportion to its own width, and the last row has fewer of them sharing the same width — so one leftover photograph stretched to fill it and stood two or three times as tall as the gallery above it. The list now carries a::afterthat eats the slack, which keeps the last row at the height it was aiming for. It is a pseudo-element rather than a filler item on purpose: an extra<li>would be an extra entry in arole="list", counted by every screen reader that announces how many there are. The Flutter build already left its last row unstretched.PlImageno longer hides a picture that had already arrived. The component moved out of its loading state on the<img>'sloadevent alone, and an event is only heard by something already listening: a file served from the cache — or one a server rendered, so the browser began fetching it while parsing the HTML — can finish decoding before React attaches the handler. The picture then sat atopacity: 0behind its own placeholder for good. It now asks the element where it got to on mount and on everysrcchange, so a picture that is alreadycompleteis shown rather than waited for.
Added
PlImagetakes afilter. Six named treatments —grayscale,sepia,saturate,desaturate,contrastanddim— and anything else you pass is used as a CSSfilterchain exactly as written. It is set through a--p-filterslot and rides the house transition, whichfilterwas already on, so a treatment swapped on hover travels at the same pace as the picture's own fade instead of snapping while the fade is still moving. The placeholder and the fallback are left alone.PlImagetakes awatermark. A bare string sits in the bottom corner; an object says where it goes, how visible it is and at what angle.placement: 'tile'covers the whole picture, which is what a proof or a preview wants — a corner mark is cropped off in a second. A tiled mark is one repeating SVG background rather than forty or fifty elements, and the layer is turned as one layer so the repeat has no seam. It is drawn only once the picture has arrived, isaria-hidden, takes no pointer, and follows the picture intopreview.PlImagetakes aprotect. Refuses the context menu, a drag out of the page, a text selection over the picture and — the one that is easy to forget — the iOS long-press callout, which on that platform is the context menu. It is a deterrent and not a lock, and the documentation says so: the file is still one request away. A caller's ownonContextMenustill runs and cannot turn the refusal off, and the refusal follows the picture intopreview. There is no Flutter equivalent, because a Flutter app paints its pictures onto a canvas and there is no per-picture menu to refuse.PlTabsanswers the wheel. A bar with more tabs than room is a scroll container like any other, and a mouse has one wheel that points down the page — the one direction the bar does not run in — so the reader who could see there were more tabs had nothing to reach them with but the arrow keys, which also change the selection. A vertical wheel over an overflowing bar now moves it along, exactly as it does over aPlScrollZone.wheel={false}turns it off, a bar whose tabs all fit never takes the gesture, and a bar that runs down the side is left alone.PlScrollZoneandPlTabstake anoverscroll.'contain', the default, or'auto', spelled after CSS's ownoverscroll-behaviorand shared asPlassOverscroll. See below for what the default changes.
Changed
A
PlScrollZoneno longer hands the wheel back to the page at its ends. The pointer being on the shelf is the reader saying which of the two things under it they meant to move, and reaching the last card is not them saying something else — so the page used to start moving at a pixel nobody chose, in the middle of a flick. The newoverscrolldefault is'contain', andoverscroll="auto"is the old behaviour. Evenautonow keeps a gesture that was scrolling the strip a moment ago and hands the page the wheel only once the reader has paused. Two things keep this from being a trap: a strip everything fits in is not a scroller and holds nothing back either way, and only the axis the strip runs on is contained, so a finger sliding down a horizontal shelf still scrolls the page.PlImage's preview overlay is now a separate chunk.previewis off by default and the overlay is several times the weight of the picture component that opens it, so it is reached throughReact.lazy— asPlGallery's viewer already was. That takes 21 kB gzipped off the first paint of any page importingPlImage— it measured 26.8 kB and the overlay was 25.4 kB of it — and the same 21 kB offPlGallery, which draws its tiles with aPlImageand whose own lazy viewer had been undone by this one import. Nothing to configure, and no API change; on a cold cache the overlay now appears a moment after the first press.The size budget covers
PlImageandPlGallery, the two components whose heaviest feature is off by default, so a static import added back to either is caught rather than reviewed past.The two runtime dependencies move their floor up:
@base-ui/reactto^1.8.0andhighlight.jsto^11.12.0. Neither release changes an API this package uses.
Documentation
The documentation was audited and rewritten for accuracy and plainness. Three claims were wrong and are corrected:
PlImagesaid there is no gallery component, the component index said every component has a Flutter half, and the component counts had gone stale (133 previews on the index, 130 components registered byplass-ui/tailwind.css, 127 shared between the two frameworks).The section headings name the section. 135 of them were essay titles (
What it answers before it knows,Which floating surface,Why it is not a PlCard) and are now the shapeCONTRIBUTING.mdasks for. Every anchor link moved with them, and three links that were already broken are fixed.The prose reads as reference rather than as an essay. The em dashes are gone from both locales, 2,841 of them, leaving only the table cells that mean "not applicable". Around thirty of the longest paragraphs are split into sentences with the rationale taken out and the facts kept, the
is not X, it is Yconstruction is gone where the negated half added nothing, and a 661-character explanation that had been copied into six pages is one clause per framework and a link.The Korean pages read as Korean. 해요체 no longer drifts into 합쇼체 pages, 구어 축약 is written out,
말합니다/주장합니다/손을 뻗다as literal renderings of say, assert and reach for are replaced by the verb the sentence actually needs,가지고 있다becomes~이 있다, and 31 ledes that were noun-phrase fragments are complete sentences.The home page and the READMEs read as an introduction. The headline says what the library is, the feature cards are one or two plain sentences each, and the internals that belonged on a reference page are gone from them.
1.4.0 (2026-09-05)
Added
PlWindowPane. A window, drawn the way one of eight systems draws it, with anything at all inside it.It is not a real window and does not pretend to be one: there is no desktop, no z-order and no dock. What it is is a frame that behaves — the title bar drags, the corners resize, the three buttons are real buttons with real names — so a screenshot of an app, a demo of a feature or a piece of a landing page can be shown as the thing it will be rather than as a picture of it.
Nothing here is transformed. A dragged window moves on
left/topand a resized one changeswidth/height, which keeps the text inside it at whole pixels through both gestures: atranslate()would resample every glyph in the window for the length of the drag, which is exactly what the house rule against transforming a surface exists to prevent.oscoversmacos,macosx(Aqua), the five Windows the title bar actually changed on, andlinux. Which buttons a window has is the caller's decision; what order they sit in is the system's.minimizerolls the window up to its title bar rather than sending it anywhere, because a page has nowhere to send it — the content stays in the tree, marked inert.sizescales the chrome and nothing else, which makes this the third component afterPlBoxandPlMockupwhere the ladder is not a control height. Left out,activeworks itself out: a window is in front until another one on the page is pressed or takes the focus, and a click on the page around them changes nothing.Nothing drawn is a copy of any of those systems: a bar, a border and three buttons at the proportions they used, and no mark, wordmark or icon belonging to anyone else.
internal/drag, the scaffold every pointer drag needs and none of the arithmetic: three listeners, adata-dragging, the document's text selection taken for the length of the gesture, and a teardown an unmount can call.Four words in
PlassLabels—minimize,maximize,restoreandresizeWindow— translated in all six locale bundles.PlMockup. A device with a screen you can put anything on: a phone, a tablet, a monitor or a laptop, with the system's own bars drawn on it.The screen is a real viewport at the device's own resolution — an
mdphone is 390 by 844 — and the whole device is then scaled once to whatever room it has been given. So the content inside is laid out against a screen rather than against the page: a 390-pixel column wraps where it would wrap on a phone, and the mockup can be 200 pixels wide without the content knowing.That scale is the one
transformin the library. The rule it excepts is about controls, where a scale resamples the label under the pointer pressing it; nothing here is pressed, and the scale is set once from the space available rather than changing on an interaction.sizesets the resolution rather than a height or a type scale — the second component afterPlBoxwhere the ladder means something other than a control height. The system's bars each take their own space rather than covering the content, because a caller putting a screenshot in a mockup wants all of the screenshot; the cut-out is the exception, being a real hole in the glass, and is drawn whether or notsystemUiis on.bezel="none"is no hardware at all rather than a thinner frame, andfinishis fixed colours rather than theme tokens: a graphite phone is graphite on a page switched to dark. Landscape turns the screen, the bezel and the cut-out together; a desktop ignores it, because its stand does not turn.The reference data — five resolutions per device, three shells, six systems' worth of chrome — lives in
internal/mockup, for the reasoninternal/chartdoes.PlTimelineChart. Work against time — a row per thing, a bar per stretch of it.The two axes are a set of rows and a calendar, which makes this a
PlBarChartturned on its side with the baseline taken away: every bar starts where its own data says rather than at zero, so what the chart is about is when rather than how much. It is notPlTimeline, which is a list of steps and draws no axis at all.A row takes
PlassTimelineSeries, whose data are spans rather than values. There is no legend and nohidden: the rows are the category axis, already named down the side, and a twenty-entry legend restating them is not a filter anyone wants.Overlapping spans on one row are moved onto lanes of their own, by the greedy interval packing every scheduler uses. A row with no overlaps stays in a single lane, so the common row is exactly as thick as it was. Lanes are assigned in start order but stored against the span's original index, because that is the order the arrow keys walk and a layout decision must not reshuffle it.
A span is cut to the plot rather than to the data: a bar that runs past a pinned
minstops at the edge and says there is more of it off the side, where one drawn past the edge would say the axis is wrong. A zero-width span keeps a hairline, so a milestone is still something on the row. Both ends round, unlike a bar chart — neither end of a span is a zero.The table under it is a row per span rather than the usual grid, because two rows of a Gantt have no columns in common.
PlassTimelinePointandPlassTimelineSeriesinsrc/types.ts.PlHeatmapChart. A magnitude per cell, coloured rather than measured.Two shapes of one idea. A
gridis for two categorical axes and one magnitude — which hour of which day, which cohort in which week — where a bar chart of the same data would be forty bars nobody can scan. Atreemapis for parts of a whole with more parts than aPlPieChartcan hold, and it is the same component because the data is the same shape: a row of a heatmap and a group of a treemap are both a named series of named magnitudes.Colour here encodes size and not identity, so it comes off a one-hue ramp rather than the categorical palette. One ladder covers the whole chart rather than one per row, because the colour of a cell has to mean the same number wherever it is — which is the entire promise a heatmap makes.
scale="diverging"is for a value with a middle that means something. It is read from that middle rather than from the bottom, and both arms reach as far as the further one, so a set running from −2 to +40 does not paint every negative the deepest blue there is.The treemap packing is squarified rather than sliced: a slice-and-dice treemap of twenty values ends in slivers a pixel wide, and a sliver's area is unreadable however exact it is. A tile's area is its share, so a negative value stays in the table and off the picture.
A label that does not fit its cell is dropped rather than clipped — a missing label sends the reader to the tooltip and a clipped one sends them nowhere.
PlGaugeChart. One number on a scale that is known in advance, drawn as a dial.It is a
PlMeterbent into an arc, and the two are deliberately the same idea in two shapes:value,min,maxandthresholdsmean exactly what they mean there, so a page can move a reading from a bar to a dial without changing what it says. It is not aPlPieChartwithshape="semi"— a pie is parts of a whole and every slice is a category, while this is one value against a scale and the unfilled arc is the rest of the dial rather than a second category.sweepis degrees opened symmetrically about twelve o'clock:180for a dashboard tile,270for the instrument shape,360for a ring. The dial is sized against the box rather than assuming a circle, because how far it reaches below its centre depends on the sweep — which is what keeps a wide, short card from drawing a thin band with an empty half above it.The reading is real text rather than a label painted into the drawing, so it can be selected and found, and its size is solved against the room the hole actually leaves rather than fixed:
38and10,000%are the same prop. The arc travels to a new value as a length — a stroke's drawn fraction — because an outline is not a property CSS can transition, and a dial that scaled would resample the numbers written across it.ticksis off by default. A gauge on a dashboard is read as a proportion, and rim marks are for an instrument somebody takes a number off.showRangeis dropped past 330° whatever it says, because by then the two ends have come within a label's width of each other.PlassThreshold, insrc/types.ts, and the band rule behind it ininternal/threshold.PlMeterandPlGaugeChartnow read the same list the same way, which is what stops a quota turningdangeron a bar but not on the dial beside it.PlMeterThresholdis unchanged as a name — it is an alias of the new one, so nothing a caller imports has moved.PlSparkline. A chart with everything taken away except the shape.No axes, no grid, no legend, no tooltip. It is not a small chart, it is a different thing: a word-sized picture that goes inside a sentence, beside a
PlStat, or in a table cell, and says which way something has been going. Every number it could label is one the surrounding text already has, which is why it labels none of them.shapeislinefor a trend,areafor a quantity orbarfor a count of discrete things — the same three sentences the full charts say, at a size where nothing else is being said at all.It scales itself to its own range, so the strip is always full. That is what makes it readable at twenty pixels tall, and it is also the trap: two of them side by side are on two different scales, so the one that climbs more steeply may be the smaller number.
minandmaxare how a row of them becomes small multiples instead.baselinedraws a rule across the strip and is pulled into the range if it sits outside the data.endDotmarks the last point that is a point, not the last slot; bars do not take it, because a bar already ends where it ends. The colour is given directly rather than off the palette — one series and no legend leaves nothing to hand out.Without a
labelthe strip is taken off the accessibility tree entirely, because an unlabelled image announced as an image is noise beside text that already has the numbers. With one it is a namedrole="img"and the values are written out beside it.PlScatterChart. Two numbers per point, and whether they move together.Both axes measure, which makes this the only chart in the library with no categories: there is no column a mark belongs to and no order the points could be shuffled out of. A point with a
zis drawn as a bubble and one without it as a dot, so a scatter and a bubble chart are the same component reading the same data.A
zis an area and not a radius. Encoded as a radius, a value twice as large draws a mark four times the size; the square root keeps the ink proportional to the number. One scale covers the whole chart and is taken over every series including the hidden ones, so switching a legend entry off does not resize the rest. Bubbles paint largest first, because a small one inside a big one is otherwise invisible and the usual fix — half alpha everywhere — would undo the contrast the palette was solved for.shape="auto"draws circles while colour alone can carry identity and switches to a shape per series from the fourth on. That threshold is measured against this library's own palette, not inherited: taking the first three slots, the closest pair under deuteranopia is ΔE 64 on the light sheet and 51 on the dark one; add the fourth and those fall to 4.9 and 2.8. A series carrying its owncolordoes not count against it.The table under the chart is a row per point rather than the usual grid — two points that are both the fifth of their series have nothing to do with each other, and a shared row would invent a relationship. Its columns take the axis labels, falling back to
x,yandz.PlPieChart. Parts of a whole, at a glance.The narrowest chart in the library and the easiest one to misuse. An angle is a poor thing to compare — two slices within a few percent of each other are indistinguishable, and a reader cannot rank six of them — so the pie is right for exactly one question: is one of these most of it? Anything finer, and anything past six slices, is a
PlBarChart.It takes one list of slices rather than a list of series, because that is what a pie is: the slices are the entities here, so each takes a palette slot of its own and the legend lists them. The colour follows the slice and not its size, so a chart that is refiltered or resorted keeps every category the colour it had.
A
nulland a zero are both left undrawn. Neither has an angle, and a slice of no width is a slice a reader cannot point at.shapeispie,donutorsemi. Asemitakes the whole height as its radius rather than half of it, because it only draws the top half — reserving room for the bottom would leave a blank band under the chart — and its centre then sits half a radius below the middle, which is what puts the arc in the middle of the tile.valueLabels="all"writes each slice's share and not its value: a share is what a pie is a picture of, and the value is one hover away. A label wider than the slice it belongs to is dropped rather than clipped, so it can never end up sitting over the neighbour it would then be labelling. The gap between two slices is a constant on screen rather than in the data, so it subtends a wider angle on a small pie than on a large one.The plot is a tab stop and the arrow keys walk the slices, with the value announced in a live region as the focus moves. Everything else — the legend, the tooltip panel and the hidden table — is the same frame every other chart uses.
PlBarChart. Lengths, compared.A bar says how much, and it says it by being longer — which is the whole reason its axis starts at zero and cannot be talked out of it. Crop the scale and a bar twice as long stops meaning twice as much, and the reader has no way to know it happened.
A
nullis a gap and the bar is simply not drawn, which matters more here than anywhere: a zero-length bar and a missing bar are the same picture, and only one of them is honest.orientation="horizontal"is the right answer whenever the category names are words — it has a whole column for them where a vertical chart has the width of one bar — and everything swaps with it: which band each axis reserves, which way the grid and the crosshair run, and which end of a bar is rounded.Grouped bars answer "which series is bigger here"; stacked bars answer "what is this total made of". They are different questions and the chart should be asked only one of them at a time. The gap between two stacked segments is taken off the far end of each, so the stack still totals the right length and the seam is the sheet showing through rather than a line drawn on it — a border around a bar is ink that is not data. The two arms accumulate separately, so a series that dips does not shorten the one above it, and the baseline is redrawn over the bars because every one of them starts there.
roundedcuts the corners off the data end only: a rounded foot makes the axis look scalloped.PlAreaChart. A line with the space under it filled — which changes what the chart is about.A line says where a value went. An area says how much of something there was, and stacked it says how that amount was made up. That is the whole test for reaching for it instead of a
PlLineChart: if the quantity does not add up to anything — a temperature, a rate, a score — the fill under it is decoration, and a chart with two of them is two washes fighting.Unstacked bands are a wash fading downward, so two of them overlapping stay readable and the line along the top is what carries the value. Stacked bands take a flatter, opaquer tint, because there the fill is the mark and a band that faded out would have no bottom edge — and a stacked band is deliberately not also given a line along its top, since a stroke between two marks is ink that is not data.
stacked="full"normalises every category to a hundred, so the chart stops being about size and starts being about share. The normalising is a change to the data rather than to the drawing, which is what lets the axis, the tooltip and the table all agree that the number is a share — and the tooltip still carries the number the caller passed, because a chart that can only tell you percentages has thrown the data away.The baseline is always zero, which is the one axis rule it does not share with the line chart: an area's fill is its magnitude, so a cropped scale makes the band's thickness meaningless.
Measured with
npm run size: −0.1 kB on the whole library and nothing anywhere else. The component is forty lines on top of the frame and the line painter that shipped withPlLineChart; the difference is compression noise, and it is what the foundation was for.PlLineChart, and the foundation every chart after it stands on. A value against time, or against anything else with an order to it.The line is the mark that says change: it claims the space between two points is a journey rather than two separate facts, which is true of a temperature and false of four product categories.
A
nullis a gap and never a zero — a sensor that was offline, a month that has not closed yet. The line breaks across it, and a point with a gap either side is drawn as a dot rather than dropped. A chart that renders missing data as zero reports an outage as a collapse;connectNullsbridges it and should stay off unless the gap is an artefact of collection.curve="smooth"is a monotone cubic and not a spline. A plain spline overshoots between two close points, so a series that never goes below zero draws a curve that does: a chart is allowed to be curved and it is not allowed to show a value that is not in the data.The value axis leaves zero out, and a bar chart's will not. A line encodes a position, so cropping the scale moves every point by the same amount and the shape survives; a bar encodes a length, which stops meaning anything the moment it starts from 98.
The palette is eight hues in a fixed order —
--plass-chart-1through-8— and it is the one place in the library where a colour is not a semantic role: a series is an entity, not a severity. Every slot clears 4:1 on the light surface and 4.9:1 on the dark one, and adjacent pairs are at least 10.4 apart in OKLab under simulated protanopia and deuteranopia, which is the pair that touches in a stack or a legend. Slot one is the page's ownprimary, so a one-series chart looks like it belongs. Slots follow a series' index in the array it was passed, never its position among the visible ones: a reader who learned that Europe is blue has learned something a filter is not allowed to take back. There are also a five-step sequential ramp and a five-step diverging one, for a mark whose colour is a magnitude rather than an identity.Two internals arrive with it and are the reason the next eight charts are small.
internal/chart.tsis the arithmetic — scales, nice numbers, band scales, path building, colour — and knows nothing about React or SVG.internal/chart-frame.tsxis everything a chart draws that is not its marks: the axes, the grid, the crosshair, the legend, the tooltip, the empty state, the hidden table a screen reader reads instead of the picture, and the measurement that turns a percentage width into the pixels an SVG needs.One new label,
chart, in all seven packs.Measured with
npm run size: +8.4 kB on the whole library and +0.0 kB on all four other scenarios — the foundation is most of it, and the eight charts after this one pay for it once.PlGallery. A set of pictures, arranged — four layouts, captions, a pointer treatment and an optional lightbox, with everything but the arrangement identical across all four.gridis a contact sheet: every tile the gallery's own ratio, whatever shape the file is.masonrykeeps each picture's proportion and stacks the columns.justifiedkeeps the proportions and fills every row to the edge — the only arrangement where nothing is cropped and no space is left over.quiltedis a grid whose tiles span more than one cell, packed densely so a tile too wide for the space left drops to the next row that fits it and a later, narrower one fills the hole.Nothing is measured. Every layout runs off the item's own
ratio, so a wall of forty photographs is right in the frame the browser first paints and never reflows as the files arrive — the bargainPlImage'sratiomakes, one level up.A masonry deals across before it deals down. CSS
columnsfills the first column top to bottom before it starts the second, so a set numbered 1 to 12 reads down the left edge and the first three pictures a reader meets are stacked on top of each other. The dealing is ininternal/gallery.tsbecause the Dart build needs the same answers: a masonry with two orders is a masonry whose order means nothing.captionisbelow,overlayorhover, and a tile with no words draws none whatever it says — a row with one caption and three gaps is worse than a row with none.hoverislift,dimorzoom, andzoomis the design language's named exception to the no-transform rule: what moves is a photograph inside a frame that stays exactly where it was, with no text on it to resample.previewopens a lightbox with the rest of the set an arrow key away, behind aReact.lazyso a wall of thumbnails costs nothing for a viewer nobody opened. It is deliberately not aPlCarousel— no autoplay, no wrap, and the arrows stop at the ends rather than looping back to a photograph the reader has already seen.A tile is a button only when pressing it does something, and its name is the picture's own words plus where it sits — "A harbour — 1 of 6".
itemLabelis how that sentence is written in another language, and it is a callback rather than a string with slots because the word order differs. One new label,gallery, in all seven packs.Measured with
npm run size: +4.0 kB on the whole library. The four narrower scenarios moved too, by 0.2 to 1.9 kB, and none of them contains a line of gallery code — checked against esbuild's own metafile. A second lazily-loaded module changes how the bundler splits the chunk the entry pulls in, which is also whyscripts/size.mjschanged in this commit: it now builds with splitting on and counts the entry chunk plus every chunk statically reachable from it, rather than inlining everyimport()whether or not the module that made it survived tree-shaking.PlCodeBlock. A viewer for one line of code or a thousand — a bar over it, numbers down the side, a prompt in front of every line, and twelve palettes to read it in.Everything it draws above the code is optional and off one prop each, because the same component has to be a snippet inside a sentence — no bar, no numbers, no chrome — and the full transcript at the top of a README.
It is the one surface in the library that is not made of glass. Every other sheet here is translucent and takes the page's colour family; this one paints its own opaque ground and refuses the family entirely, because the palette is the subject rather than the setting. A Dracula block tinted
primarywould be a Dracula block nobody chose.Twelve themes: four the house's —
dark, which is the default and the one that is not a preference, since code has been read on a dark ground since terminals;light;auto, the opt-in for a block that would rather follow the page; andmono, which has no hue in it at all and carries the structure by weight — and eight ports kept at the hex they were published in.themetakes any string, so a[data-code-theme='ours']block of custom properties is a theme with nothing to register and nothing to import. Sixteen slots, five of them derived from the other two.The line numbers and the prompt are generated content, and that is the whole point of drawing them that way: a
$a reader drags across and pastes into their shell is a$their shell chokes on. Neither can be selected, found by find-in-page, or copied.highlight.jsis the package's second runtime dependency, and it is reached only through a dynamic import — the core plus one module per language, thirty-five of them, never the package root. A page that importsPlCodeBlockand never highlights anything downloads none of it, and a page that highlights TypeScript downloads the core and TypeScript and not the other thirty-four. It is a realdependenciesentry rather than an optional peer because a specifier a bundler cannot resolve fails the whole build, not just the part that would have used it. Dart is in the built-in set, which highlight.js's own "common" list leaves out and half of this project's pages are written in.Five new labels —
copy,copied,copyFailed,rawandcode— in all seven packs.Measured with
npm run size: +3.2 kB on the whole library and +0.0 kB on all four other scenarios. The grammars are not in that number and are not meant to be: they are chunks animport()fetches, one language at a time, andscripts/size.mjsnow bundles with splitting on and counts only the chunk a page loads to render.PlTreeSelect. A value chosen out of a hierarchy rather than out of a list — aPlTreebehind the same field every other picker wears.It is the two of them composed and almost nothing else. The trigger is
PickerShell, the popup is aPlTreewith aPlTextFieldover it, and what the component adds is the arithmetic between them: which nodes a query keeps, which branches that opens, and which of the ids coming back out of the tree are answers rather than roads.selectableBranchesis off by default, which is the shape most of these trees actually have: the branches are the taxonomy and the leaves are the answers, and a "Europe" held alongside "France" is usually a data model nobody meant. A node's ownselectableoverrides it either way, and a branch that cannot be chosen still opens and closes — pressing it is how you get at what is under it.Which is why turning a branch press down is not the same as clearing. A single-value tree hands back exactly one id, so treating an unusable one as an empty answer would empty the field every time somebody opened a folder. The press is dropped and what is held stays held.
searchablekeeps every match and every ancestor of one. A tree filtered to bare matches is a list, and a list of leaves is exactly what a tree was chosen over — a "Seoul" with nothing above it does not say which taxonomy it came out of. Every branch the filter kept is opened, since a match folded inside a shut parent is a match nobody was shown, and emptying the field hands the folds back to the reader untouched. The fold isinternal/search's, sojosefindsJoséhere exactly as it does in aPlCommandPalette.multiplekeeps every node a press adds and leaves the popup up, because a picker that shut after the first of several answers would have to be reopened for each of the rest.formatwrites the trigger however the caller likes, andnameputs one<input type="hidden">on the page per held id.No new label:
search,emptyandclearwere already words this library says.Measured with
npm run size: +0.9 kB on the whole library and +0.0 kB on all four other scenarios.PlTour. 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 is
PlHowToStepsturned inside out. That component puts the instructions in the page and the reader follows them; this one leaves the page as it is and stands over it. So a step says what it is about rather than describing it: what a tour points at is already on screen, and a second copy inside the card is a second copy to keep in step.The dimming takes the pointer and the light does not, and the whole component rests on it. The scrim is one layer over the viewport with the target cut out of it as a clip rather than as a painted hole, and a clipped-away region is not hit-tested — so the reader can use the control being pointed at and nothing else. That is the difference between a tour and a dialog with a picture of a control in it, and it falls out of the geometry rather than being a second mechanism that has to agree with it.
The same clip is why the dimming can blur. A hole drawn as a box shadow or as four rectangles around the target can only paint a colour; a clipped layer carries a
backdrop-filter, so the page around the light is out of focus as well as dark — this library's own material rather than a grey wash over it.targettakes a ref, a selector or a getter, and the ref is the one to reach for: a selector is a string that stops matching the moment somebody renames a class, and the tour would go on running with the hole over an empty piece of background. The selector is offered because it is the only form that works for an element the page did not render.Escape, the ×, Skip and Done are the only ways out. A press outside the card is ignored and so is the focus leaving it, because using the page is exactly what a tour is for.
openandstepare each controllable on their own,onFinishruns before the tour closes — which is where "this reader has seen it" goes — and the component remembers nothing itself.The counter is two numbers rather than a sentence: "3 of 7" is a string that has to be translated and a word order that differs by language, and the count itself does not. One new label,
skip, in all seven packs.Measured with
npm run size: +1.5 kB on the whole library and +0.0 kB on all four other scenarios.PlDataTable. A table that owns its rows: it sorts them, narrows them to what was typed, hands them out a page at a time and remembers which of them are ticked.PlTablestays exactly what it was, and the split is not a size decision. A table whose every column is arendercallback is the one component in this library a React Server Component has to be able to render, and reading a context would take that away — so the component that has to remember four things between renders is a second component rather than a prop on the first.Below the columns they are the same grid. The measured widths, the hover band, the rule between rows, the pinned header and every inline style that keeps a host stylesheet's
td { border: 1px solid }off them now live ininternal/table.ts, and both components draw out of it. Two copies of that is how the rows under a sorted table end up a shade off the rows under a plain one.Sort, search, selection and page are each uncontrolled by default and controllable one at a time. That is what lets one component cover both of the tables people build: the ordinary one is
columnsandrows, and a table backed by a server is the same markup withmanualand four handlers. Nothing in between changes shape.Sorting rotates ascending, descending, then back to the order the rows arrived in. That third press is the part most tables leave out and it is the one that matters: the arrival order is usually the order the server chose, and a table that can never be put back has thrown it away. The mark is drawn faintly on every sortable heading rather than appearing under the pointer, because a heading that only looks pressable once you are on it is a heading nobody presses. Values are compared as what they are, text with
localeCompare— sorting by code point puts every capitalised word above every lower-case one — and nothing sorts last in both directions, because a blank in a column of amounts is not the smallest amount.A column's
valueis what the sort and the search see, whererenderis what the reader sees. Most columns need neither; the moment a cell is drawn rather than printed the two come apart, and a total column printing$1,240.00sorts as a string that puts$89after it.The selection hands back rows from every page, not from the page on screen, and the header box goes indeterminate when part of a page is chosen — a half-filled page under a plain unticked box reads as the opposite of what is true. Shift extends the range in the order the rows are currently in, which is what a reader dragging down a sorted page means by "these". A chosen row carries
aria-selectedas well as the tint, and a press on the tick is not also a press on the row.aria-sortgoes on the heading, not on the button inside it: the heading is what a screen reader reads on entering a cell in that column. The sort control is a bare<button>wearing the heading's own type, because aPlButtonthere would be a control on a control — a background, a radius and a height inside a cell whose job is to sit flush against the rule under it.Four things it deliberately does not do, and the page says so rather than leaving them to be looked for: it does not virtualize (the honest answer is
paging="pages", which is also the only shape that works when the rows are being fetched), it does not drag-resize or reorder columns (both need somewhere to persist what the reader dragged), it does not export (the application's data and the application's filename —toolbaris where the button goes), and it sorts on one column (a sort three keys deep is a query the reader cannot see).One new label,
selectRow, in all seven packs.Measured with
npm run size: +2.8 kB on the whole library and +0.0 kB on all four other scenarios.Locale bundles: the library's own words, translated. Every component that says something of its own — a close button's name, a pager's landmark, the line an empty list shows — now reads from one set of sixty-four strings, and seven translations of that set ship with the package.
tsximport { PlassProvider } from 'plass-ui'; import { ko } from 'plass-ui/locales'; <PlassProvider locale="ko-KR" labels={ko}> <App /> </PlassProvider>;de,en,es,fr,ja,koandzhHans. The list is short on purpose: a pack is worth shipping once somebody who reads the language has read it.Each pack is a named export from its own module rather than an entry in a
locales['ko']table. A lookup would be the shorter API and the wrong one, because for a key to be found in a table the table has to be in the build — a French application would ship the Korean strings, the Japanese strings and the rest. An import ships one, and an application that switches language while it runs imports the packs it offers and picks between them, which is what it already does for its own copy.A key is a meaning, not a component.
closeis the × on a modal, a drawer, a popover and a toast, and it is translated once. A key exists per component only where the word genuinely differs:paginationNextmoves by a page andcarouselNextmoves by a slide, so a language that distinguishes those has somewhere to put the distinction.PlassLabelswidens what used to be the pickers' ownPlassPickerLabels, which had already grown past the pickers; the twenty-five components that were carrying their own English string now take it from the set. Nothing about the precedence changed — the component's own prop still wins, a partiallabelsstill leaves the rest English — so an application that named its words by hand keeps working exactly as it did.Measured with
npm run size: +0.7 kB on the five overlays and +0.8 kB on the whole library, which is the registry being shared rather than inlined;PlButtonandPlTypographyalone are unchanged. A pack is about 0.9 kB gzipped and is only in the build if it was imported.Three hooks:
usePlDisclosure,usePlElementSizeandusePlOnScreen. Each of them is a piece of machinery the library already ran on internally, and each is public because the hand-written version has a decision in it that is easy to get wrong.usePlDisclosureis one boolean and the four callbacks that change it. It is worth a hook rather than a snippet for one reason: written by hand it is auseStateplus three arrow functions that are new on every render, and an inline() => setOpen(false)handed to a memoised trigger defeats the memo it was handed to. Every callback here is stable,onToggleincluded, because it uses the updater form rather than!open. The names are the props —setOpenfitsonOpenChangeexactly — so the ordinary use is two lines.usePlElementSizeadds two things to aResizeObserver. The first measurement is taken in a layout effect rather than waiting for the observer, whose first callback arrives after a frame has been painted — a component that laid itself out from0 × 0for that frame flashes. And it reports the content box: the room actually left inside the element once its padding has been taken off, where a hand-written version nearly always reportsgetBoundingClientRect()orborderBoxSizeand gets a number that includes the padding and the border.usePlOnScreenhasonceon by default. The question a caller almost always has is "has this been seen yet" rather than "is it on screen right now", and a hook that kept answering the second one would re-render a page of lazily loaded pictures every time the reader scrolled past any of them. It answersfalsebefore it knows, which is safe for both of its uses, andtruewhere there is noIntersectionObserver, because a picture that never loads is worse than one that loads early.Measured with
npm run size: +0.4 kB on the whole library and +0.0 kB on all four other scenarios, the three of them together.PlAnimateSplit. A line of text arriving one part at a time. The other effects tell themselves off across their children, which a line of text does not have — so this one makes them, and hands the set to exactly the samestaggermachinery aPlAnimateFadearound a list of<li>s uses.effect,stagger,durationStepandreverseall mean what they mean everywhere else; the component is the splitting and nothing more.by="character"is not safe in every script, and the page says so rather than leaving it to be discovered. A character part breaks the shaping between letters: Arabic stops joining, Devanagari conjuncts come apart, and an emoji built out of several code points is cut into its pieces.wordhas none of those problems and is the default.The gaps are not parts. Whitespace is never given an entrance of its own — animating the space between two words is nothing arriving — and it does not take a step of the stagger with it either, so the second word starts one step after the first rather than two. That is why this component reaches for
useAnimationRundirectly rather thanuseAnimateElement, which is the arrangementinternal/animate.tsalready describes for the components that have to understand their own children.Each part is
inline-block, because a transform does not apply to a non-replaced inline element and a slide would otherwise fade without moving.A screen reader is told the line, once. The parts are hidden from the accessibility tree and the whole line sits beside them, which is what stops a split headline being read out one word — or one letter — at a time: the defect this pattern is known for everywhere it appears without it.
Measured with
npm run size: +0.4 kB on the whole library and +0.0 kB on all four other scenarios.PlAnimateScramble. A line of text resolving out of noise, and the second of the two effects that animate content rather than a box.The noise is made of the line's own characters. Every scrambler that ships with a default alphabet ships an English one, and over a Korean, Greek or Arabic headline that is not a word resolving — it is a different script flickering where a word is about to be, and a reader watching their own language arrive out of somebody else's is watching a bug. Shuffling the line's own glyphs is right in every script and costs nothing; it also keeps the line's colour and width steady, because every frame is drawn out of exactly the characters the finished line is made of.
charactersoverrides it for the caller who genuinely wants a terminal look.It settles left to right rather than at random — a word arriving is something a reader can follow, and a reader who looks away and back has not lost their place — and whitespace is never scrambled, because the gaps between words are what keeps a line of noise looking like a sentence.
The redraw is stepped at
tick, 45ms by default, rather than taken every frame. At sixty a second a line of changing glyphs strobes, which is unpleasant to look at and is exactly the flicker a reader with a sensitivity to it must never be handed.It takes a
stringrather than a node for the reasonPlAnimateCountertakes a number, waits to be seen for the same reason, and tells a screen reader the line once and never the noise.Measured with
npm run size: +0.5 kB on the whole library and +0.0 kB on all four other scenarios.PlAnimateCounter. A number counting up to what it is — the one effect in the group that animates content rather than a box.It is not a keyframe, and the reason is formatting. A registered custom property and a CSS
counter()tick a number perfectly well, and that would be the neater implementation; what they cannot do is put a thousands separator in one, or a currency symbol, or fold 1,200,000 into1.2M. A counter that cannot be formatted is a counter nobody can put on a dashboard, so the frame loop decides only which number is being drawn andIntl.NumberFormatdecides what it looks like.easingis a function here for the same reason: there is no CSS animation running to hand a string to.triggerdefaults tovisible, and it is the one component in the library that does not start on mount. An entrance played off screen has still delivered its content; a count that ran off screen delivered a number that was already sitting there, which is the one thing a counter cannot afford — being watched is the whole point of it.A screen reader is told the answer, once. The ticking figure is hidden from the accessibility tree and the final number sits beside it in a clipped span, because a number changing sixty times a second in that tree is either silence or sixty announcements and neither of those is the figure. Until the count starts, what is shown is the number it will count from — the same rule every keyframe here follows about its own first frame — so nothing claims a value it has not reached.
Changing
valuecounts again from wherever the last one landed, so a figure that updates on a timer needs nothing said to it.Measured with
npm run size: +0.3 kB on the whole library and +0.0 kB on all four other scenarios.PlAnimateShake. A refusal. The one effect in the set that is a response rather than an entrance — what a form does when the password was wrong again, what a locked control does when it is pressed — so it starts held still, where every other effect here starts on mount.replayis the prop it exists around, and it is a new shape in the animation API. A refusal can happen twice, andplay— being a boolean — cannot say "again": replaying with it means toggling off and on, which is two renders for one event and a piece of state whose only job is to be flipped back. A value that has changed is the closest React has to an event, and the count of failed attempts a form already keeps is exactly that value. It never plays on the first render, because a shake that shook itself on mount would be answering an event that has not happened.It lands exactly where it started — three shudders either side of home and back to nothing. That matters more here than anywhere else in the group, because this is the one effect a caller will run over content that is still being typed into, and a field left a few pixels off its label would be a worse defect than the error it was reporting.
It is not in
PlassAnimation, forPlAnimateFloat's reason: that union is the set of ways content can arrive, and a response is not an arrival.The page says the thing the component cannot enforce. A reader who asked for less motion sees none of it, so whatever the refusal is saying has to be said in text as well — the field's own
errorandinvalid— and the shake is emphasis, never the message.Measured with
npm run size: +0.2 kB on the whole library and +0.0 kB on all four other scenarios.PlAnimateFloat. Content drifting gently, and not going anywhere. It is the odd one out in the group and the page says so: every other effect here is an entrance, played once when content arrives, and this one never finishes.Three things follow from that. It repeats infinitely by default, because one drift out and back is a nudge and nobody asks for a nudge. It has no
mode, because the cycle is symmetric and running a symmetric cycle backwards is the same cycle. And it is not inPlassAnimation.That last one is the decision worth recording. The union is the set of ways content can arrive, and an object literal does not tree-shake per key — so every component importing the effect map pays for each row whether or not it uses it. An endless drift is not an arrival and is a row nothing else could ever want, which is exactly the line the
PlAnimateRevealentry drew when it joined the union. It runs its own keyframe instead.The cycle is home, out, home, so however many times it runs it ends where it started: a float stopped mid-cycle would leave the element permanently a few pixels out of place, which reads as a layout bug rather than as an effect that ended.
easingdefaults toease-in-out, and it is the one component in the library that does not take the house curve. The house curve is an entrance's — fast out of the gate, slow into place — and a drift with it lurches at each end of the cycle instead of turning around, because there is no gate: the element is already there and is only breathing.Measured with
npm run size: +0.2 kB on the whole library and +0.0 kB on all four other scenarios, which is what staying out of the union bought.PlHowToStepsandPlHowToStep. Instructions, numbered, with what to do under each one. Three components in the library now put things in order and the difference is not the drawing: aPlStepperand aPlTimelineboth say where you are — one in a process the reader is moving through now, the other in a sequence that has already happened — and this one says what to do.That is the shape everything else follows from: every step's body is open at once. Somebody following instructions reads ahead, goes back a step, and works at their own pace, so a guide that showed one step at a time would be hiding the answer to "what am I about to be asked for".
It is also why
activeis optional here. A guide that claimed to know how far a reader had got would be guessing; it is for the guide that genuinely knows, such as a setup wizard reporting what it has already done.numberedis on by default, because "do this, then this" is an order and the number is how a reader finds their place again after looking away. Off — usually withconnector="none"— is a checklist, which has no order. Aniconreplaces the number in the disc and keeps the step's place in it: a step never takes an index, the guide numbers its children as it walks them, and a conditional step that rendered nothing does not take a number with it.It is a real
<ol>of<li>s, and that is the accessibility of it. A screen reader announces "list, five items, item two" on its own — the position a heading per step would only approximate — so the numbers a reader sees are the ones the list carries.The bullet, the halo and the connector come from
internal/steps.ts, the same table the stepper and the timeline draw from, because a haloed bullet must not mean two things in one library.Measured with
npm run size: +0.6 kB on the whole library and +0.0 kB on all four other scenarios.PlFloatingActionButton. The one action a screen is about, floating over it. It is aPlButtonin a corner and everything that makes it one is the button's — the three materials, the elevation ladder, the pointer light,loading,readOnly,disabled. What it adds is the pinning, the shape, and one rule.labelis required and is always the accessible name. A floating button is a disc with a mark in it nine times out of ten, andextendeddecides only whether the words are also drawn — never whether they exist. An icon-only button with no name is the single most common accessibility defect this pattern ships with, and a required prop is the only fix that survives review.The icon-only form is a disc, which is
PlIconButton's deliberate exception to the radius rule: the flat run along a control's edge is there for a line of text to sit on, and a glyph has no line of text. The extended form is not a pill for exactly that reason — it has words along its edge, so it takes the house fillet like every other labelled control.elevationdefaults to 3, the top of the ladder, and unlike every other default in the library that is not a compromise: this is the one control that genuinely floats over the content rather than resting on it.sizedefaults tolg, one step up from a button's, because it is a target for a thumb.The insets are logical and inline:
cornerisstart/endas everywhere, so the button crosses the screen under RTL with everything else, and an inline declaration is the one form that wins over a utility deterministically — which a caller'soffsetneeds, being a value rather than a class.floating={false}keeps the shape and the shadow and drops the positioning.The page says the two things a component cannot enforce: one per screen, because two primary actions is none; and that it covers content, so a scrolling list needs room at its end for the row underneath it.
Measured with
npm run size: +0.1 kB on the whole library, because it is aPlButtonand aPlIconButtonand nothing else.PlAnchor. A table of contents that follows the reader down the page. Anyone can draw a list of links; what is worth writing once is deciding which one is lit, and the rule is not the obvious one.The lit row is the last heading whose top has passed the reading line — not whichever heading happens to be visible. Three can be on screen at once, and the one a reader is inside is the highest of them that is already above them. That is why the tracking is a measurement rather than an
IntersectionObserver: an observer answers "is it visible", and the question here is "which one did I pass last".Two ends are handled separately, and both are what a hand-rolled version gets wrong. Above the first heading nothing is lit, because the reader has not reached a section and lighting the first row before they arrive is a claim about where they are. At the very bottom the last row is lit whatever the measurement says, because a short final section never reaches the line and a list without this goes dead exactly where a reader is looking for it.
offsetis where that line sits — the height of whatever is pinned over the page. Without it a heading goes on counting as the next one after it has slid out of sight behind a sticky header, so the list sits a section behind the reader for the bar's whole height.The headings arrive as a flat array rather than as children, which is the opposite of most of this library: a table of contents is generated, and generators produce a flat list in document order with a level on each entry. It stays flat, deliberately — real documents skip levels, so a nesting built from that list would be a guess at a shape nobody wrote.
Only where there is something to scroll: a page that fits on the screen is always at its own bottom, and lighting the last row there would say a reader had reached the end before they had read anything.
It measures at most once per frame, because scroll fires far more often than a page paints and the answer cannot change between two paints. The lit row carries
aria-current="location"— where the reader is within the document, and notpage— with a rule down its leading edge, so colour is never the only thing saying where they are.Measured with
npm run size: +0.6 kB on the whole library and +0.0 kB on all four other scenarios.PlDataListandPlDataListItem. A list of labels and the values that go with them — the panel every detail screen ends with, and the whole reason it is a component is the markup. It is a real<dl>with real<dt>s and<dd>s, each pair grouped in a<div>, which the HTML specification allows and which is what lets a row be laid out side by side without giving up the grouping that makes it a pair.A details panel built as a two-column table is the mistake this replaces, and it is not a styling one: a table claims a row-and-column relationship that is not there, so a reader navigating it by cell is told there are two columns of data when there is a column of names and a column of values. One thing and its fields is a description list; many things with the same fields is a
PlTable; a run of items of the same kind is aPlList.The label column is a fixed width rather than the width of the longest label, which is what makes two panels on one screen line up and what stops a value moving when somebody renames a field.
labelWidthtakes any length, and'12ch'is usually the right one — a label column is measured in characters and no ladder ofremcan spell that.dividerrules between the rows and only between them: a line above the first or below the last would be a box drawn around a list that has no box.The rows are children rather than data, unlike a
PlTable's columns. A details panel is written out once and read in source order, and every value in it is a different shape — a chip, a date, an avatar, a link — so a data array would be an array ofrenderfunctions.Measured with
npm run size: +0.4 kB on the whole library and +0.0 kB on all four other scenarios.PlAppLogo. A product's mark and its name beside it. The whole component is the framing, and the default is the answer project after project gets wrong:baredraws the artwork as it was given, at the heightsizeasks for and whatever width that comes to.That is the default because most marks already have a frame. A mark drawn with its own background, its own margin, or the product's name set into it is finished artwork — putting it on a plate gives it two edges, and cropping it to a circle cuts the name in half.
plateandcircleare for a mark drawn as a bare glyph, which cannot sit next to anything else until it has been given an edge, and a plate insets the artwork to about seventy percent of the tile rather than filling it: the margin every app icon has.It is not a
PlAvatar, and the page says why. An avatar is a picture of a person or a thing, always a circle or a fillet, with initials behind it when the picture does not arrive, because there is always something to draw. A logo is artwork the product owns: it has no fallback worth inventing, and its shape is a decision somebody already made — which is why the shape is a prop here and a house rule there.With a
name, the mark is taken off the accessibility tree. The wordmark beside it already says what the product is called, and a picture that says it again is a screen reader reading the name twice. Without one the mark speaks, through analtwhose empty default is a real answer.variantandcolorare read only when there is a plate: a bare mark is the product's own artwork and the library does not tint it.render={<a href="/" />}makes it the way home, which is nearly always what a logo is.Measured with
npm run size: +0.5 kB on the whole library and +0.0 kB on all four other scenarios.PlHoverCard. A preview of what is behind a link, shown when the pointer rests on it. The library now has all three floating surfaces, and the pages tell them apart by what opens them and what a reader can do once they are open rather than by how they look — all three are the same sheet. A tooltip names the thing under the pointer and nothing in it can be reached; a hover card previews what is behind it and the pointer can move onto it; a popover was asked for, stays until it is dismissed, and can be typed into.The rule that decides whether it is the right component at all is written on the page: nothing may live only in here. A card that opens on hover does not open for a finger, so a link, a button or a fact that exists nowhere else is one that every touch reader misses. Everything inside is a preview of something already reachable — which is what makes it safe to have, and why it needs no dismiss button, no focus trap and no scroll lock.
The delays are the component.
delayis 600ms and deliberately long: a card that opens the moment a pointer crosses a link opens on every link a reader passes on the way somewhere else, which turns a page of prose into a page that flinches.closeDelayis 300ms and cannot be zero — the gap between the trigger and the card has no pointer in it, so a card that closed the instant the pointer left could never be reached, and reaching it is the whole difference from a tooltip.It opens on keyboard focus as well as on hover, so a reader tabbing along a paragraph of links gets the same preview a pointer would. The trigger is rendered rather than wrapped, so a link keeps its
href, its styling and its place in the tab order. The sheet is aPlPopover's, onesizerung wider at every step: a popover is a detail beside a control, and a preview squeezed to the width of a hint is a preview nobody reads.Base UI's
PreviewCardowns the anchoring, the flip at the window edge, the two delays and the dismissal.Measured with
npm run size: +3.4 kB on the whole library and +0.0 kB on all four other scenarios — the primitive, paid for only where the component is.PlScrollArea. A bounded box that scrolls, with the library's own scrollbar in it. The reason to reach for it overoverflow: autois the bar: a platform scrollbar is either an overlay that vanishes the moment the content stops moving or a strip of permanent grey furniture, and neither of them belongs beside a translucent sheet. The thumb is--plass-track, the same neutral ink aPlSlider's rail and a progress groove are cut in, and the lane is overlaid so drawing it costs the content no width.It is not a
PlScrollZone, and the pages say which is which. A scroll zone is the other answer to the same fact — content that runs off the end of its box — and it takes the bar away entirely, fades the end that still has something behind it and adds a pair of buttons. That is right for a strip of tabs or chips, where a bar under one line of labels is heavier than the labels. A scroll area is right for a panel of content, where the bar is the honest signal and where a reader wants to know how far through they are, which a fade cannot say. There is deliberately no fade here for the same reason: two signals for one fact, one measured and one not, is one more than the box needs.heightis a prop rather than a class, which is the one API decision worth stating: a vertical scroll area has to be bounded by something or there is nothing for the content to overflow, so it is the measurement without which the component does nothing at all.maxHeightis the ceiling form for a panel that should shrink to short content;widthandmaxWidthare the pair for a horizontal one.orientationtakesboth, which draws a lane on each edge with a corner in the join.scrollbarsisauto— while the pointer is over the box or the content is moving — oralways, which is right more often than it looks: for a panel whose whole point is that there is more below, a bar that appears on hover is a signal nobody standing back from the screen ever sees.labelis worth giving and the reason is not obvious. A scrollable box is a tab stop when nothing inside it is focusable, because somebody using a keyboard has to be able to scroll it, and a landing point with no name is announced as nothing at all. With a name it is a region; without one it claims no landmark, deliberately, because an unnamed region is something a screen reader lists as "region" and nothing else.Base UI's
ScrollAreaowns the behaviour: the overlay measurement, the thumb's size and position, the drag, and making the viewport a tab stop exactly while there is something to scroll.Measured with
npm run size: +4.7 kB on the whole library and +0.0 kB on all four other scenarios — the Base UI primitive, which is the largest of the ones the library imports and is paid for only by the pages that import this component.PlMeter. A quantity inside a range, drawn as a bar. It is cut out of the same groove as aPlProgressLinearand it is deliberately not one, because the difference decides which to reach for: progress is something advancing, and a meter is something already known. Disk used, seats taken, a password's strength, how full a battery is — none of them is going anywhere on its own.That follows into the API and into the semantics.
valueis required and there is no sweep, because a meter with nothing to report is not a meter but a bar that should not have been drawn yet; and the role ismeterrather thanprogressbar, since telling somebody a static figure is in progress is telling them to wait for something that will never finish.thresholdsis the prop it exists for. Bands of{ from, color }, where the highestfromat or below the value wins — read rather than walked, so the order they were written in does not matter — andfromis in the meter's own units rather than a percentage. A quota bar that turns amber at three quarters and red at ninety percent says something a fixed colour cannot, and the colour comes from the value rather than from the moment somebody was looking. The page pairs it withshowValueevery time it appears: a band is a second way of saying how full something is and must never be the only one.Base UI's
Meterowns the semantics — the role, the range attributes,aria-valuetext, the formatting and the fill width — the same way itsProgressowns a progress bar's, so what is left here is the material: the groove is--plass-trackand the fill the family's gradient, travelling on the width. A value outside the range is clamped on both halves, the bar and the announced value together, so what is read out and what is on screen never disagree.Measured with
npm run size: +1.0 kB on the whole library and +0.0 kB on all four other scenarios, the Base UI primitive included.PlPortal. Children rendered somewhere else in the document. It iscreatePortalplus the three things a library has to add, and the first is the only real reason to reach for it.It carries
plass-portal. Every surface the library already sends through a portal — a modal, a drawer, a menu, a popover, a tooltip, a toast — lands with that class on it, because a portalled subtree leaves whatever element a host had scoped its CSS reset to and the class is how that host finds it again. A caller's own portal without it was the one subtree on the page the reset missed.It renders nothing until it has mounted. There is no
documenton a server, so the HTML that ships never contains a portalled subtree and neither does the hydrating render. That is what a portal is rather than a limitation to work around, and the page says so: anything that has to be in the server's HTML does not belong in one.containeris resolved after mount, which is what lets it be a ref. The element a portal targets is usually one React has not created yet at the moment the prop is written, so a ref isnulland agetElementByIdfinds nothing; reading the prop during render would get the wrong answer every time. An element and aDocumentFragmentare taken as they are, a function is called, and anything that resolves to nothing falls back todocument.bodyrather than dropping the children.What it does not carry is the colour scheme, and that is written down rather than left to be discovered. The stylesheet answers to a
.darkor a[data-theme]on an ancestor, and a portal to the body has left every ancestor it had. The library's own popups have the same gap and the same fix: pointcontainerat an element inside the theme. React context does cross a portal — the tree it is read from is the React one — so aPlassProviderabove still decidessize,color,densityandlocale.It is React-only: an
overflow: hiddenancestor and a stacking context that cannot be escaped from the inside are DOM problems, and Flutter's answer is theOverlayevery app already has.Measured with
npm run size: +0.1 kB on the whole library and +0.0 kB on all four other scenarios.PlFlex. A row or a column and the gap between the things in it — the layout box with no arithmetic in it. A PlGrid divides a row into twelve columns and needs aPlGridItemto take them; a PlStack overlaps what it is handed. This one only decides which way its children run.directionis responsive and resolves in CSS, which is the reason it is worth a component rather than three Tailwind classes.directiontakes{ xs: 'vertical', md: 'horizontal' }, a form that stacks on a phone and lines up on a laptop, decided by the stylesheet: one--p-dir-*slot per rung the caller named, cascaded by the same@variantblocks a grid's columns already ride. So a server renders it correctly at every width, dragging a window costs no re-render, and no listener is installed. It is also the only way to say that in a project that importsplass-ui/styles.cssand has no Tailwind of its own.reversefolds into that same slot instead of taking one of its own, and that is why it is deliberately not responsive: one custom property carries the whole answer, so a breakpoint changes the axis without having to restate which end it starts from. It is a painting order and moves nothing in the document — what a screen reader reads and what the Tab key walks is unchanged — so it is for an arrangement and never for content whose order is the information.wrapdefaults tofalse, unlike aPlGrid's. That is what a flex box already does, and the opposite default would silently reflow a toolbar somebody had sized to fit.The two gutters are now declared for
.plass-gridand.plass-flextogether rather than copied. They are one question asked by two components — the space between the things in a box — andspacingis the same Tailwind scale on both, so a flex box's gap and a grid's gutter of4are the same1rem.It is React-only, and that is not an omission:
Row,ColumnandWrapare already inpackage:flutter/widgets.dartand already take aspacing. The half worth having in Dart is the responsive axis, and that is aLayoutBuilderrather than a widget. The component page shows it.Measured with
npm run size: +0.2 kB on the whole library and +0.0 kB on all four other scenarios, because nothing else imports it.PlHeaderandPlFooterhold their content to the same measure aPlContainerdoes, from one implementation. All three took amaxWidthon "PlContainer's ladder" and all three carried their own copy of it — three tables that could disagree, and a bar whose measure did not line up with the container under it is a page with a visible seam down one edge. There is one ladder now, ininternal/responsive.ts, and all three take the same type: a rung, any CSS length, a number of pixels, ornone, responsive, resolved in CSS.Six components turn with the window.
orientationonPlPanes,PlTabs,PlScrollZone,PlTimelineandPlStepper, anddirectiononPlStack, all take a responsive value:orientationtakes{ xs: 'vertical', md: 'horizontal' }and a tab bar is a column on a phone and a row on a laptop.These resolve in JavaScript rather than in CSS, and that is not an oversight. An orientation decides which DOM a component builds, which
aria-orientationit claims and which way its arrow keys walk; no stylesheet can do any of that. So they pay what every JavaScript answer about width pays — a server renders thexsentry and the browser corrects it on hydration — which is exactly whyPlContainer'smaxWidth, which only decides style, does not come this way. The line is written down ininternal/responsive.tsand on the breakpoints page.A bare value subscribes to nothing.
useMediaQuery(null)is a hook that installs no listener, so the four queries are only asked when there is a map to answer with: twenty tab bars that never change shape install no listeners between them. Measured — 0 fororientation="vertical", 4 for a map.PlShow. Content at some widths and not others —from,until, or both as a band. It decides in CSS, which is the whole component rather than an implementation note: a media query answered in JavaScript isfalseon a server and on the first frame a browser renders, so auseMediaQueryand a ternary draw the wrong half of a responsive layout and then throw it away. That is a flash on every page load, not an edge case. It is also the only way to gate on width for a project that importsplass-ui/styles.cssand has no Tailwind of its own — there is nomd:hiddento reach for there.It is not a box. While showing it is
display: contents, so its children take part in the surrounding layout exactly as they would have without it: a gate inside a flex row does not become a flex item. Which also means aclassNamecarrying a margin or a width lands on nothing — put your own element inside.What it costs is that both halves are in the document. Hiding is
display: none, which takes the subtree off the accessibility tree and out of the tab order, so nothing is read out twice and nothing is drawn — but both were rendered and both were sent. Right for two arrangements of the same content; wrong for a subtree that fetches, is expensive, or must not mount at all, andusePlBreakpointValueis what mounts exactly one of those at the cost of a server rendering thexsanswer.untilis exclusive, sountil="md"on one element andfrom="md"on another are the two halves of one decision with no width that draws both and none that draws neither. There is noxsbound — everything is at or above the bottom rung, so it would mean "always" and "never" — and there is noPlHide, becauseuntilis already the inverse offrom.A
PlContainer'smaxWidthis responsive, and takes any length.maxWidthtakes{ xs: 'none', md: 'lg' }and narrows a page as the window widens, andmaxWidth="72ch"is the measure a paragraph actually wants — the five rungs areremand no ladder ofremcan spell a measure in characters. A number is pixels; the rungs are unchanged.It resolves in CSS, not in JavaScript, and that is the part worth knowing: one
--p-maxw-*slot per rung the caller named, cascaded by the same@variantblocks the grid uses. So the first paint a server sends is already right at every width, and a window being dragged costs no re-render. Measured in a browser:noneat 500px,1024pxat 900px,72chat 1400px, from one declaration.The machinery moved out of
internal/grid.tsintointernal/responsive.tswith it, since it now has a second user. That file also writes down where a responsive value can be resolved and where it cannot: a value that only decides style belongs in CSS, and a value that decides structure — an orientation changes the DOM, the ARIA and the arrow keys — has to be JavaScript and pays the server'sxsfirst render for it.The breakpoint ladder is written down once, and a project can move it. Every width in the library now comes from Tailwind's own
--breakpoint-*theme, so one line moves both halves of it:css@theme { --breakpoint-md: 50rem; }The reason it takes an arrangement rather than a prop is worth stating: a media query's condition cannot read a custom property.
@media (width >= var(--x))is not valid CSS, so no provider and no runtime value can move a breakpoint the stylesheet decides at. What the CSS half can do is resolve the theme at build time, which is what@variantdoes —PlGrid's four cascade blocks were hand-written@media (width >= 48rem)literals and are@variant mdnow. Measured: a@themeoverride moves a@variantblock to 50rem and leaves a hand-written one at 48.The JavaScript half asks
matchMedia, where a breakpoint is a value rather than a condition — so it can read the same answer off the document.--plass-breakpoint-sm…--plass-breakpoint-xlare four new tokens carrying exactly that, each falling back to Tailwind's default so they are still right on a server, on a first paint before the CSS has arrived, and on the precompiledplass-ui/styles.csswhere our values are baked in.usePlBreakpointand aPlSidebar'scollapseBelowboth read them, andinternal/breakpoints.tsis the one place that does.A new package test fails the build if a width is ever written out by hand again, which is the failure that would otherwise be invisible: a page whose
md:utilities change at one width and whosePlGridchanges at another still renders, still passes every component test, and is simply wrong on a laptop.PlAnimateReveal. Content uncovered behind a moving edge, and the only entrance in the set where nothing moves and no colour changes. A fade changes the ink, a slide changes the position, a grow changes the size; this changes how much of the element is drawn and leaves every pixel it has drawn exactly where it will finally be. That is what makes it the effect for anything whose position is the information — a heading over the paragraph it belongs to, a divider between two sections, a chart's plot area, a column of figures that must not be read from the wrong place.It is a
clip-path: inset()rather than a mask or anoverflowwrapper, and both alternatives were considered. A mask brings a gradient that has to be kept in step with the direction; a wrapper puts a second element into the layout, which is exactly what an effect for something positional must not do.inset(0)already means "nothing is cut off", so the to-state is the element's own natural one rather than a number somebody had to choose — and the page around it never learns that anything happened.fromis physical —top,right,bottom,left— asPlassSideis everywhere, andmode="out"closes from whichever edge it opened towards.fadeis off by default, alone among the effects that offer it: fading a reveal asks for two entrances at once, and the reason to have reached for this one is usually that the first was the problem. It takesstaggerandtimeline="view"like the other five.The union it joined cost nothing, which was the thing to check before adding to it.
revealis a seventh row inPlassAnimationand in the class map that backs it, and an object literal does not tree-shake per key — so every component importing that map pays for the row whether or not it uses the effect. Measured withnpm run size: +0.2 kB on the whole library and +0.0 kB on all four other scenarios, because nothing outside thePlAnimate*family imports the map at all. An effect that was not an entrance — an endless drift, a response to a rejection — would not have gone in it.timeline="view"hands an effect to the reader's scroll position. Two CSS declarations behind an@supportsgive every keyframe in the library a scroll-linked mode: the effect stops being something that happens at a moment and becomes something that tracks where the element sits in the scrollport, so scrolling back up plays it backwards and a reader who stops halfway leaves it halfway. There is no new component and there should not be — this changes what advances an effect, not what the effect is.Four settings stop meaning anything and are ignored rather than half-working:
duration,delayandrepeatbelong to a clock, and so does the whole idea of atrigger, since the scroll position is the trigger. That last one is load-bearing rather than tidy — an effect heldpausedwaiting to be scrolled into view shows its own first frame and nothing else, however far it is scrolled — so a scroll-linked effect is always let go.pausedstill works, because that is a caller saying "hold it" rather than "wait for something".rangereplacesduration: ananimation-rangeas CSS writes it, defaulting toentry 0% cover 45%, which finishes while the element is still arriving rather than when it reaches the middle of the screen.A browser with no
animation-timelinegets one clock-driven run, which is what the@supportsis for: degraded is allowed, blank is not. The slots are written only when a caller asks forview, so the default costs nothing —autois what the property already resolves to, and writing it into every inline style would be the same answer a hundred times. On the same six effectsstaggeris on and absent from the same four, becauseanimation-timelineis a property of the element the keyframe runs on. +0.1 kB on the whole library.Six effects can now be told off across their children.
staggeris milliseconds added to each child's delay,durationStepis the same for its duration, andreversestarts from the end of the set — onPlAnimateFade,PlAnimateGrow,PlAnimateSlide,PlAnimateZoom,PlAnimateRotateandPlAnimateBlink.staggerdefaults to0, which plays the box, so nothing that exists changes.There is deliberately no
PlAnimateStagger. A stagger is a differential rather than an effect, and a wrapper would be a second way to spell something all six can already say — the same rule that keeps aPulse(blink+alternate) and aBounce(grow+alternate) out of the library. Turning it on takes the animation off the root entirely: eight children fading in under a box that is also fading in is the same content faded twice, and the second one is not free.The four effects that already read their children do not take these and cannot — a marquee lays its children down twice, a headline swaps between them, a typewriter counts their graphemes, and a lighting keeps its motion on a pseudo-element, which there is no way to put on somebody else's child.
PlAnimateAppearhas had the same three props under the same names since it existed and now runs on the same code:animateChildrenandstaggerSlotsmoved intointernal/animate.ts, because two implementations of "one after another" would be two opinions about the arithmetic. It gainsdurationStepon the way past.The effect is written onto the children themselves rather than onto wrappers, so a row of
<li>s stays a row of<li>s and a grid's cells stay its direct children; the cost is that a child has to accept aclassNameand astyle. Thetransform-originaPlAnimateGrowwas given travels with it, since that property is not inherited and a staggered grow would otherwise unfold every child from its own middle. +0.2 kB on the whole library, nothing on any component that does not import an effect.
Fixed
The chart frame carried nine slot names this package does not declare. Ported from the library the frame came from, renamed to the
--plass-prefix, and never checked against anything:--plass-grain,--plass-sheen,--plass-panel-press,--plass-plate-glassand--plass-disabled-fghave no Plass equivalent under those names, and--plass-chart-gapwas simply never written down. Nothing failed. An unresolvablevar()computes tononein astrokeand to nothing at all in a shorthand, so what shipped was a tooltip panel with no glass, no shadow and acurrentColorborder, a legend whose switched-off entries were the same ink as the rest, and a line chart whose markers had no ring cut out of them.The panel is now the sheet a
PlSelect's popup is, which is what it should always have been — a floating readout and a floating list are the same material. A switched-off legend entry is the muted ink with a line through it and its swatch keeps its own colour at 40%, which is what the Flutter build already drew.--plass-chart-gapis declared as the surface, andPlassTokennames it.Two checks were added so this cannot ship again.
test/package/tokens.test.tsnow asserts that every--plass-*a source file reads is declared instyles.css— the two checks already there compared the type against the stylesheet, so a token appearing in neither, which is exactly this case, agreed with itself. Andtest/styles/standalone.test.tsx, the one file in the suite that loads real CSS, now renders a chart and asserts that nofillorstrokeon it computes tonone. That catches the other half: a token that is declared but resolves to nothing because its declaration sits under a selector no chart matches. Neither test asserts a shade.The chart frame's focus ring and legend hover were unstyled. Three of the ported frame's colour slots kept the names they had in the library they came from —
--n-soft,--n-ringand--n-line— and nothing in this package declares any of them. Avar()that cannot be resolved takes anoutlineshorthand down with it, so the focus ring on a chart's plot and on every legend button was simply absent: the plot is a tab stop, and a tab stop a keyboard reader cannot see is the accessibility failure the ring exists to prevent. The legend's hover tint was gone with it, and the tooltip panel's hairline fell back tocurrentColor, which drew the border in ink. All three now read the slots aPlBoxactually sets —--p-soft,--p-ringand the glass hairline.A chart ignored the
sizeandlocaleaPlassProviderset.CartesianCharttook both as plain props with hard-coded fallbacks, so a provider that put the whole page onsmleft every chart atmd, and one that named a locale still got the reader's own number formatting on the axis. It resolves both from the defaults now.densitydeliberately does not go through it: the box resolves that one itself, and the single chart that needs it for arithmetic reads it directly.A read-only
PlSelectopened its popup again. Base UI 1.8 redefinedreadOnlyon a select to mean "nothing in the popup can be chosen" and lets it open regardless; every other picker in this library promises that a read-only control's popup does not open at all, and a select that opened beside a date picker that did not is the form that looks assembled rather than designed.PlSelectnow holds the popup's open state itself and keeps it shut whilereadOnly— held rather than passed conditionally, so a field unlocked while it is on screen does not switch between controlled and uncontrolled underneath Base UI. An open the lock swallowed is dropped rather than remembered: recorded, it would spring the popup open the moment the read-only lifted, so a form that unlocks a section would drop a menu over it at nobody's asking.A chart's grid, axis rules and baseline were invisible.
internal/chart-framedraws all three withvar(--plass-chart-grid),var(--plass-chart-axis)andvar(--plass-chart-baseline), and none of the three was ever declared — an unresolvablevar()on astrokecomputes tonone, soPlLineChartandPlAreaChartshipped with no gridlines, no axis rule and no baseline at all. The faint horizontal lines visible in the docs were the demo canvas's own background showing through the translucent sheet.The three are declared now, derived rather than picked so a project that re-tones its border moves the grid with it: the grid is the border at 70%, the axis is the border, and the baseline — which is where a bar starts from and where zero is, a fact about the data rather than furniture — is the muted foreground at 35%.
PlassTokennames them, which is what the package test that caught the omission checks.A
PlTree's branch dropped in and vanished rather than opening. It was the last fold in the library that did not travel: an accordion, a collapsible and a pill all move a height over 260ms, and this one swapped its rows in and out between two frames — under the twisty that had just been pressed, which is exactly where a reader is looking. The branch is a Base UICollapsiblenow, written with the two lines aPlCollapsible's panel is written with.Folds nest exactly, which is the part worth checking rather than assuming: Base UI sizes an idle-open panel
autoand only measures a pixel height for the length of the transition, so an outer branch contains an inner one opening inside it frame for frame with nothing to catch up to. Measured in a browser — 192px, then 224px with an inner branch open, with the outer atautothroughout.The cost is that a shut branch's rows are built and not mounted, because rows dropped from the document on the frame the twisty turns have nothing to travel. React discards the elements, so it is a cost of building rather than of rendering, and
children: undefineduntil a branch is opened is the answer for a tree with hundreds of closed folders in it. The Flutter build does not pay it — a fold there takes a callback — and the tree page says so. +0.3 kB on the whole library and nothing at all on a bundle that does not importPlTree, measured withnpm run size: the primitive was already on the graph forPlAccordionandPlCollapsible.A
PlSlider's thumb teleported to any value nobody dragged it to. An arrow key, a press on the rail, a value set from outside — the thumb was in one place on one frame and somewhere else on the next, on the one control whose whole subject is where along here. It travels over the house duration now, and the run behind it fills at the same rate.Nothing at all while the pointer is down. A thumb that eased towards a finger would be a thumb lagging behind it, which reads as the control being slow rather than as the motion being smooth, so
data-draggingzeroes the duration on both parts. The properties areinset-inline-start/bottomand the indicator'swidth— the ones Base UI writes the value into — rather than atranslate, which is why this does not bend the no-transform rule: nothing is shifted off its own place, because the thing being moved is the value.A
PlTree's twisty jumped between its two angles. It carried the house transition and nothing else, and the house transition deliberately has norotatein it — so on a span whose only colour is a constant it was a transition of nothing at all. The turn names itself now, the way an accordion's and a select's chevrons already do. It matters more here than the size of it suggests: the twisty is the only thing on a row that says whether the branch is open, and every other chevron in the library turns.A tick and a radio dot arrived whole, on the frame the box filled. Both were the last state change in the library still expressed as a swap: the mark was there or it was not, on a control whose whole job is to answer a click, and the mark is the part a reader is looking at.
Neither is scaled into place, because controls do not move and that rule is not bent for the small parts. A tick draws itself along its own path — the stroke is dashed at exactly its own length and slid out of view by exactly that, so how much of the mark exists is one number, and
pathLength="1"renormalises the tick and the dash to the same 0-to-1 whatever their real geometry is. A dot grows out of the middle of its ring — the ring centres a fixed-size child, so a width and a height are laid out about the same point and nothing outside the ring can be moved by either.Both marks are now kept in the document at nothing rather than unmounted, because Base UI waits for animations on the indicator itself and not on the
<path>inside it — an exit would otherwise be cut off on its first frame, and a control that animates one way and cuts the other means two different things by the same click.A
PlSelectand aPlComboboxpopup arrived and left between two frames. Every other floating surface in the library fades — a modal, a drawer, a menu, a tooltip, a popover, a date picker's sheet — and these two were the ones that did not, so the same gesture had two different answers depending on which control the reader was standing on. They fade now, on the same one line the others use.Opacity only, which is the point rather than a shortcut: a list that slid or grew in would be dragging its own options across the field they are being read against, and the options are the thing being looked at. Base UI keeps the popup in the document for the length of the ending transition, so a list closes the way it opened instead of vanishing.
Three things that were written as fades were not fading.
opacitywas missing from the house transition's property list, and three states are expressed in this library as an opacity and nothing else:opacity-50is what every disabled control looks like, aPlBackToparrives and leaves onopacity-0, and aPlImageuncovers itself the moment the picture decodes. All three changed between two frames — a button that blinked into the corner of the page, a photograph that replaced its own skeleton, a key that went grey the instant it was disabled.It is one word in one array, and the reason it is worth a line here is the rule it restores rather than the size of it: a value that carries a state has to be in the transition that carries states. The list is otherwise unchanged,
transformincluded — it is still not in it and still should not be.A
PlTabsbar that scrolls now says so. The bar has always scrolled when there were more tabs than room — what it could not do was tell anyone. A macOS scrollbar is an overlay that appears while the strip is moving and is invisible the rest of the time, which is every moment a reader is deciding whether there is more to look at; the Windows one is fifteen pixels of permanent furniture under a row of labels. Both are taken away and the ends are faded instead — only the end that still has tabs behind it, which is what makes it a signal rather than a decoration. The fade is amask-imagerather than two gradient overlays because a bar can sit on anything, and an overlay would have to be painted in the colour of a background the component cannot know; taking the pixels away is right on every surface. It is dropped entirely while a tab inside is showing a focus ring, since focusing a tab scrolls it flush against the edge the fade is strongest at. Measured rather than declared — whether a bar overflows depends on the room it was given, so there is no prop that could answer it — and published asdata-overflowon the tab list (none/start/end/both, in the reader's order) so a page can style against it. +0.4 kB on the whole library and nothing at all on a bundle that does not importPlTabs, measured withnpm run size.A
PlScrollZone's inline scroll button now stands in its lane instead of hiding in it. Withbuttons="auto"and the defaultinlineplacement, the button with nowhere to go was drawn invisible so that its lane would not come and go and resize the strip under the pointer. The lane was right and the emptiness was not: the space is paid for either way, and a reserved empty lane beside a strip does not read as restraint — it reads as odd padding on one side of the box. It isdisabledand visible now, which is whatbuttons="always"already drew in the same position, so the two settings no longer disagree about what "nowhere to go" looks like. Whatautodecides is whether the strip has scroll buttons at all. Anoverlaybutton is unchanged and still goes, because there the space costs nothing to give up — remove a control when its absence is free, disable it when it is not.A
reversiblePlSpoilerchanged height when it was opened, and again when it was closed. The Hide row was drawn only once the spoiler was uncovered, so revealing grew the sheet by the height of a button and covering it again shrank it back — the page moving twice around the control somebody is pressing. The row is built from the start now and merely held invisible and out of reach under the cover, so its space is paid for once. The generalisation is worth keeping: a control that appears with a state should reserve its space in the other state, because the space is the part a reader notices.A
ghostPlTogglethat was off drew itself as one that was on the moment the pointer arrived. Its hover was--p-soft, and--p-softis exactly what aghosttoggle on is painted with — so the two states differed by their ink alone, which is not a difference a reader is going to read. The hover climbs the neutral glass ladder in all three variants now, the same two rungssolidandglasswere already using and the same wash an unselectedPlBottomNavigationitem takes for the same reason. The rule the fix is written to is worth stating, because it is the one a two-state control fails quietly: a control with two states cannot put the colour family on the false one, and a hover is still the false state.The build empties
dist/before it writes to it.tscwrites and never removes, so a component deleted fromsrc/went on being published:package.jsonships the whole directory and the./*export resolvesplass-ui/thingstraight out of it, which means a removed name goes on importing and goes on being typed for everybody but the person who removed it. Two more things read that directory rather than the source —scripts/build-styles.mjswrites oneplass-ui/css/*.cssscan manifest per folder it finds underdist/components, andscripts/minify.mjscounts@__PURE__annotations across whatever is there — so a leftover folder was also a stale stylesheet manifest and a count that quietly disagreed with the source it was a count of. None of it failed a build; it failed in somebody else's install.npm run cleanis the first step ofnpm run buildnow, and is its own script so it can be run on its own.
Changed
A
PlButtonGroupand aPlToggleGroupdraw one run of keys rather than two copies of it. The squared corners, the hairline overlap and the stacking context each key gets were written out in both components — the same three lists, and two places for the seam to stop lining up. They live ininternal/button-group.tsnow, beside the context both groups already publish. Nothing renders differently.A surface that takes the page now opens at 260ms rather than 150ms.
PlModal,PlDrawer,PlOverlayandPlCommandPalette— the sheet and its scrim together, so the two arrive as one thing.--plass-duration-slowalready existed and was already what a height travels over; it was never applied to the thing it describes best.150ms is a key going down. Across the whole window it is not a fade, it is a cut with a hint of blur on it, and a page that changes this completely that fast leaves a reader looking for what moved rather than reading what arrived. The line is written down on the motion page and it is one question: does the surface take the page, or hang off a control? A menu, a popover, a tooltip, a select's list and a picker's sheet all still open at 150ms, because they are the size of the control they came from.
Nothing about the shape of the motion changed. It is still opacity and nothing else, in both directions, on the same curve.
PlStackreplacesPlAvatarGroup, and this is a breaking change. A row of overlapping faces is one arrangement of a pile rather than a component of its own: a deck of cards, a stack of documents, a fan of thumbnails and a row of avatars differ in what is in them, not in how they are laid out.PlStacktakes whatever it is given and never looks inside.The overlap is real layout, never a transform, and that is the whole component. A translated pile is laid out one item wide: it paints outside its own box, and every element after it on the page is placed against a size the reader never sees — so it cannot go in a paragraph, a table cell, or a flex row beside a label without pushing something out of place. A negative margin makes the box measure exactly what it draws. Five 32px items at 10px of overlap: 120×32
horizontal, 32×120vertical, 120×72diagonal, measured in a browser with the stylesheet loaded rather than reasoned about.That last one is the part most implementations get wrong. A flow only overlaps on the axis it flows along, so
diagonalflows across likehorizontaldoes and takes its vertical step per item instead — one fixed offset in a row would put every item at the same height. It is also whydiagonalis a fan and says so: the horizontal advance isitem width − overlap, and a component that takes arbitrary children does not know that width.max,totalandoverlapcarry over unchanged.overflowis new and is a function —(n) => <PlAvatar initials={\+${n}`} />— because the hidden count _is_ that item, and a node would have to be handed a number it has no way to work out.direction,front,scaleStep,opacityStep,dropandringare new. Depth is applied on the standalonescaleproperty inside a second box per item, which is not spare: every keyframe in this library that grows or zooms writesscale`, and on one box an entrance would overwrite the depth on its first frame.What is lost is the group context, and it could not be kept.
PlAvatarGroupsetsize,shape,variant,colorandelevationonce for every face; a pile that accepts arbitrary children has no way to know one of them is an avatar.sizeandcolorare axes an application already sets once — put aPlassProvideraround the stack. The other three belong on the avatar.PlAvatarno longer reads a group at all and resolves those five from its own props and the provider.ringis a boolean and lands on the element you passed, so it takes that element's shape: wrap an avatar in something square and the ring is square. The alternative was copying a class onto the children, which stops working the moment one of them is aPlTooltip, a fragment, or somebody else's.map(). Measured withnpm run size: +0.1 kB on the whole library, and nothing on the four other scenarios.
1.3.0 (2026-08-31)
Added
Five fields take a
hotKeysmap.PlTextField,PlNumberField,PlOtpField,PlComboboxandPlSelectnow bind chords —hotKeystakes{ 'Mod+Enter': save, Escape: cancel }. What makes it worth a prop rather than anonKeyDown— which those five already took and still take — is that the chord is spelled the way a key cap is spelled, in the vocabularyPlHotKeysdraws:Modresolves per platform, andEsc,Return,CmdandOptionfold onto the same keys their caps do. A shortcut a component displays and a shortcut it binds are now one string rather than two that can drift, which is the argumentPlCommandPalettemade when it bound its own opener — it carried a private copy of the matcher to make it, and that copy is nowinternal/keys.tswithPlHotKeysreading its alias table and its platform out of the same file. Three rules hold everywhere: the map sits on the control rather than on the stack around it, so a chord is answered by the thing that has the focus; a modifier is checked in both directions, soEnterdoes not fire onShift+Enter; and a chord that matches is consumed — not reaching the form, the dialog above it, or the browser. Which is why these are chords rather than letters: a field with{ a: … }cannot type ana. A caller's ownonKeyDownstill runs first and can overrule the map by callingpreventDefault(). The vocabulary also grew one alias,NumpadEnter, so the long spelling reaches the same key its cap does.A
PlDatePickercan stop at a month or a year.precisionis'day','month'or'year', and it is a floor: the calendar opens on the grid for that unit and pressing a cell in it answers, so amonthpicker's month grid is the last grid and there is no day grid under it at all. That last part is the point rather than a side effect — a card's expiry is a month, and a control that makes someone answer which day of December 2027 is a control that will be answered wrongly. The value stays aDate, normalised to the start of what was chosen: the 1st of the month, or the 1st of January, never whichever day or month the cursor happened to be resting on when the year was pressed. Three things follow it without being asked for — the trigger's defaultformat, the hidden input's spelling (YYYY-MM-DD, thenYYYY-MMandYYYY, which is what a native<input type="month">submits), and the footer's shortcut, which reads "This month" or "This year" rather than "Today" and brings two newlabelsentries with it.minDateandmaxDateare then read at the same precision: aminDateof 15 July leaves July pickable on amonthpicker and hands back 1 July, because a bound on a control that returns a month is a bound on months.shouldDisableDateis day-granular and is not consulted at all.PlVisuallyHidden. Content for a screen reader and for nobody else — the rule the library already applies inside a dozen of its own components, spelled once for the caller's own markup. The two obvious ways to hide something are both wrong in the same place:hiddenanddisplay: nonetake the text off the accessibility tree along with the screen, which is the one thing this exists to avoid, andopacity: 0leaves a clickable ghost the size of the words. A one-pixel clipped box is the only form that is absent to a sighted reader and present to every other kind. The ordinary use is naming a control whose whole label is a glyph — mark the glypharia-hidden, or the control ends up with two names — and the other is a live region, which is a<div>with anaria-liveand nothing to draw.focusableputs the box back in the flow while anything inside it holds the focus: that is the skip link, and it is the one variation a caller cannot add from outside, because the clip isposition: absoluteand revealing it means making the elementstaticagain. It answers:focus-withinrather than:focus, since what is tabbed to is almost always the link inside the box rather than the box itself. It is React-only, and deliberately: what it works around is a DOM problem, and Flutter's answer isSemanticswith anExcludeSemanticsunder it rather than a widget of ours.The media query machinery is public.
usePlMediaQuery,usePlBreakpointandusePlBreakpointValueare the store aPlSidebarhas always used to find out that it has become a drawer, given names — new surface rather than new code, which is why they arrive together.usePlMediaQuerytakes any query and returns a boolean that re-renders when the answer changes; the other two are the same store asked about the ladder, andusePlBreakpointValueresolves the same map shape a responsive prop onPlGridtakes, with the same rule — a bare value applies everywhere, a map applies each entry from its own breakpoint up, and it isundefinedbelow every rung the map named rather than a guess at one the caller did not write. The widths are Tailwind's own, and they are asked as media queries rather than measured offinnerWidth: the ladder is written inrem, so a reader who has enlarged their default font size would otherwise get a rung that disagrees with the stylesheet beside it. The first answer in a browser is the server's answer —false, andxs— becauseuseSyncExternalStorerenders its server snapshot through hydration, which is exactly why the library's own components pair the hook with a CSS class rather than replacing one with the other. Anything that must be right in the first frame belongs in a Tailwind variant; these are for the decisions CSS cannot make, like whether a component is mounted at all. OneMediaQueryListis now shared per query string across the whole page, whereuseCollapsedused to open one per component. They are exported from the barrel and from a newplass-ui/hookssubpath, and the documentation has aHookssection to put them in.usePlReducedMotion. The preference the library has always answered on its own behalf, given a name.prefers-reduced-motion: reduceis easy to typo and the mistake is silent, which is most of the argument for a hook over a query string. The other part is what the documentation can now say in one place: "reduced" is not "none", and the library's own components disagree with each other on purpose — an entrance is dropped entirely, because an animation that never played has still delivered everything it was carrying, while aPlProgressCircularis slowed rather than stopped, because a spinner that stopped would be lying about whether anything is still happening. The store moved with it:internal/animate.tsno longer keeps amatchMediaof its own, and the query is one more subscription shared throughinternal/media.tslike every other.usePlHotKeysbinds a chord outside any one control. ThehotKeysprop added in this cycle put the vocabulary on a field; this puts it on the page, which is where an application's own shortcuts live and whichPlCommandPalettehas needed since it existed — it carried a private window listener to bind its opener, and now calls the hook like anybody else. The three rules come with it: a modifier is checked in both directions, a chord that matches is consumed, and — new, and true of the prop as well — an event that is already consumed is left alone, so a field's binding wins over a page's. The interesting default iswhileTyping: false, and it is narrower than the name suggests: a chord carryingMod,Ctrl,MetaorAltis answered in a field anyway, because none of them can appear in its value, and so is a key that does nothing to text —Escapeabove all, since a panel that closes on Escape has to close from inside its own form. What is held back is a printable character or one of the keys that edits or moves through text, which is exactly the case worth holding back: a global/must not eat the slash out of a URL somebody is typing. Handlers are read fresh on every keystroke, so the map may be written inline and a handler closing over current state is never stale; what re-attaches the listener is the set of chords rather than the object's identity.targetscopes the binding to an element or a ref, andenabled: falseremoves the listener rather than muting the handler — a shortcut that is off should not consume the key from whatever else wanted it.PlCalendar. The grid aPlDatePickeropens, on the page. It is not a new calendar — it is the sameinternal/calendar.tsxthe four pickers already share, with the trigger and the popup taken away, which is why it arrives with the three views, the roving tab stop and the arrow keys that step the month when they run off an edge already finished. The reason to have both is what each one is: a picker is a field that happens to open a calendar and belongs in a form beside other fields, and this is a calendar that is not standing in for a field — a booking page, an availability view, a date rail — where the grid is the interface rather than a way of filling one in. So it has nolabel,descriptionorerror, and it keepsname, so a plain form can still submit what it holds.precisionis the same floor the picker's is, and the value is normalised to the start of what was chosen. Two props are deliberately absent: there is nodensity, because padding on a grid of forty-two squares is what stops them being squares, and noreadOnly, because a read-only field still shows a value a reader can select and copy while a calendar has nothing to copy — a grid whose every cell is inert is the disabled one, anddisableddraws it with theinertattribute rather than with adisabledon forty-two cells.autoFocusis off, the opposite of the picker's: a popup has just been opened by somebody who wants to be in it, and a calendar in a page has not.PlassProvidersets the defaults once. Sixty-six components resolvedsize,coloranddensityfrom a literal in their own destructuring, which meant an application whose controls aresmsaid so at every call site — not a design decision, transcription. They now resolve from the nearest provider instead, in four layers and in the order a reader would guess: the component's own prop, then whatever set it is in, then the provider, then the component's own default. So asize="lg"on one button still wins inside aPlButtonGroupinside a compact application. Providers nest and merge, so a section that says onlydensitykeeps thelocalefrom the one above it. The date vocabulary comes with it —locale,weekStartsOnandlabelsreach all five date components andPlNumberField, andlabelsmerges under each component's own so an application can translate the set once and one picker can still say something different.variantandelevationare deliberately not in it, and the absence is the load-bearing part.variantnames what a surface is made of, and aPlButtonissolidwhile aPlCardisglassbecause that is the arrangement rather than an unset default;elevationis per-component semantics for the same reason, a control resting on the sheet at1and a field cut into it at0. One value for either would not be a default, it would be a flattening.usePlassDefaults()reads what is in scope, for a caller's own component that has to line up with the ones around it.PlTableis the one component that does not read it, and stays the one exception it already was: reading a React context would make it a client component, and it is kept out of that graph on purpose, because every one of its columns is arendercallback and a server component cannot hand a function across the boundary. A new package test asserts the contract from both ends — that a provider produces byte-identical markup to writing the prop, on fourteen components, and that no component takes a style axis without reading the defaults.What it costs, measured with
npm run size— a real esbuild bundle ofdist/, gzipped, React external: +0.1 kB on a five-component form and +1.9 kB on the whole library. That is oneuseContextper component plus the resolution lines, and it is paid whether or not a provider is ever rendered, because the read is unconditional. A singlePlButtonis unchanged to the tenth of a kilobyte.usePlColorSchemeandPlColorSchemeScript. Dark mode already worked — the tokens answerprefers-color-schemeand can be forced with.darkor[data-theme]on any ancestor — and what was missing was everything around a choice: somewhere to keep it, something to write it onto<html>, and a way to paint it before the first frame. The hook is the first two.systemis deliberately the absence of a choice rather than a third theme: it removes the attribute and hands the question back to the platform, so a reader who picks it goes back to following their computer rather than being pinned to whatever their computer said at that moment.toggleflips from what is painted, so the first press on a system-dark page gives light, which is what pressing a toggle means. It writes the attribute and the class, and that is not belt and braces: the attribute is what this library's tokens read, and the class is what a consumer's own Tailwinddark:utilities read when they are configured against a class — a toggle that moved one and not the other would leave a page half switched. Two toggles on one page share one store and agree with each other, and a second tab that changes the scheme changes this one, because the choice is one the reader made about themselves rather than about a window.PlColorSchemeScriptis the third, and it is the one that has to be a server component: React runs after the document is parsed, so a theme applied from an effect is applied one paint too late and the reader has already seen white. It goes in<head>, renders nothing but an inline<script>, calls no hook and reads no context — a client component here would arrive with the bundle and be too late by definition.suppressHydrationWarningon<html>is the other half and is not a workaround: the script's whole job is to change that element before React hydrates.The stylesheet gained one declaration with them:
color-scheme, stated per theme block. Scrollbars, the caret, a native<select>popup and a date input's own calendar are the browser's to paint and none of them is ours to style — they were white on a page forced to dark, which reads as broken rather than themed.PlConfirmProviderandusePlConfirm.await confirm({ title: 'Delete this project?', color: 'danger' })resolves with the answer, so the branch after a question stays in the handler that asked it. The argument for a hook rather than a component is the same onePlToastProvidermade: what a caller has at the moment a question is warranted is a click handler, not a place in the tree — and without this, adding a confirmation to one button means a piece of state, a<PlModal>kept mounted beside it, and the work after the answer torn in half across a callback, repeated at every button that needs one.alertis the one-button form and resolves when it has been acknowledged, which is what makes it awaitable in the middle of a sequence.Three decisions worth stating. Cancel holds the focus by default: a confirm dialog exists to make somebody stop, and an Enter key that lands on the destructive action defeats the whole thing —
initialFocus: 'confirm'moves it for a question whose yes is the harmless answer. Questions asked while one is open are queued, in order, with the sheet's content changing rather than the dialog closing and reopening; the alternative is a promise nobody ever resolves, which is a hung button rather than a visible bug, and a provider that unmounts with questions outstanding resolves them allfalsefor the same reason. AndusePlConfirmthrows outside a provider rather than answeringfalse, because a silentfalseis a delete button that quietly does nothing, which is worse than a missing provider that says so on the first press.PlStepperandPlStep. A process the reader is moving through, and where they are in it. It draws the same rail aPlTimelinedoes — and shares it: the bullet ladder, the three states, the connector and the status arithmetic moved into a newinternal/steps.tsthat both components read, because a haloed bullet must not mean two things and two copies of twelve lines are two copies that drift. What differs is what each one is for, which is also the whole of when to reach for which: a timeline reports a sequence that already happened and nothing on it can be pressed; a stepper is the sequence, its steps are buttons and the current one owns a panel.activeis an index rather than a value, exactly as a timeline's is, because a stepper has no selection.linearis on by default — the third step of a sign-up cannot be filled in before the second — while a step behind the reader is always reachable, since going back to correct an answer is the whole reason a stepper is not a wizard with one door. Horizontal puts the panel under the rail and vertical puts each panel inside its own step, which is the reason to lay one out vertically at all.statusoverrides where the sequence says a step is, for the one that failed validation while the reader was three steps on. And it is deliberately not arole="tablist": a tab list owes a keyboard reader one tab stop and arrow keys, and claiming the role without the behaviour is worse than never claiming it — each reachable step is its own tab stop, and a step nobody has reached yet is not a button at all rather than a disabled one.PlTree. A hierarchy opened one branch at a time. It takes its nodes as data rather than as children — the opposite of most of this library, and the right way round here for one reason: a tree is recursive, and recursion written in JSX is a component every caller has to write for themselves.PlTabletakes its columns the same way and for the same reason.expandedandselectedare separate arrays and separate questions, because opening a folder is not choosing it.selectionissingleby default,multiplesays so witharia-multiselectable, andnonemakes the tree a browser rather than a chooser — rows still expand and a click still reports, but nothing stays lit.children: []andchildren: undefinedare different things, visibly: the first is a branch that opens and shows nothing, the second is a leaf with no twisty at all, which is what makes a lazily-loaded tree possible. The keyboard is the ARIA tree pattern and is most of what makes this a tree rather than a nested list — one tab stop for the whole thing, following the focus rather than leading it; ↓/↑ walking the rows that are actually visible; → opening a branch and then stepping into it, two presses rather than one, so a reader can open a branch without leaving the row that told them it was there; ← closing it or stepping out to the parent.PlImage. An<img>is one tag and it works, so this says what it adds rather than assuming it. Three things: the space is reserved before the picture arrives, so the paragraph under it does not move when it does — that isratio, and it is the prop worth reaching for every time, because without it there is nothing to reserve; a failure is drawn rather than left as the browser's broken-image glyph and the alt text in a serif nobody chose; and the two are one state machine, so a placeholder is never still sitting behind a picture that has loaded, and a changedsrcstarts again rather than inheriting the last one's success. The<img>stays in the document the whole time, because an<img>that is not in the document never loads.altis required and""is a real answer rather than a missing one.previewopens the picture over the page in aPlOverlayand is off by default — a picture that grows when you click it is a promise that there is more of it to see, and most pictures on a page are not making it. There is no gallery component and no next/previous inside the preview, deliberately: a gallery is a list with a state of its own, and it composes out of what is already here.PlEmpty. An empty list, a search that found nothing, a request that failed and a flow that has finished are the same arrangement — a mark, a line, a sentence, a way out — which is why they are one component and not four, and whycoloris what tells them apart:secondaryis "nothing here yet",dangeris "something went wrong", andsuccessis "you are done", which makes the order-confirmed screen without a second component. It draws no surface: an empty state is always inside a card, a table or a panel, and a sheet inside a sheet is two sheets —PlTable'semptyprop takes a node, and this is the node it was waiting for. The part worth getting right is the way out, and it is the reasonactionsis a slot rather than an afterthought: a screen that says "No projects" and stops is a dead end, and the same screen with a "New project" button is the best moment in the whole flow to offer one. It has no role of its own, deliberately —role="status"belongs on it when the emptiness is the result of something the reader just did, and not on a list that was already empty when the page loaded and has therefore already been read.PlPopconfirm. A question asked where it was raised. The difference fromPlConfirmProvideris not the words, it is how much it interrupts — a modal takes the page away and is right for the question that deserves that, and this appears against the row's own delete button, leaves the rest of the table readable, and puts a reader who changes their mind back exactly where they were. The rule of thumb is what happens if they answer by accident: if they can undo it, this is the one.colordefaults todangerhere and toprimaryon aPlButton, and that is not an inconsistency — nobody reaches for a popconfirm to ask whether to save.onConfirmmay return a promise: the button shows its loading state until it settles, the popup closes only if it resolves, and Escape is ignored while it runs, because a request in flight is not something to abandon halfway. A rejection is caught and goes no further, and that is a decision rather than an omission — keeping the question on screen is the whole of what this component owes a failure, and what the failure means belongs to the caller who returned the promise and has the context to raise a toast about it. The focus lands on the confirming button, the other way round from the modal and deliberately: a popconfirm is opened by the button it is asking about, so the reader has already said what they want once.PlBackTop. The way back up, once there is a way back up to want. It is hidden until it is useful, and that is the whole design rather than a nicety: a button pinned to the corner of every page from the first paint is one more thing covering the content, and on a page short enough not to scroll it is a control that does nothing. While it is out of reach it isaria-hiddenand out of the tab order rather than merely faded — a control a reader can tab to and cannot see is worse than one that is not there.targettakes a ref, so a panel that scrolls inside the page gets one of its own, andfloating={false}keeps the appearing and the scrolling while giving up the corner. The scroll is smooth and not underprefers-reduced-motion: a page that flies past a reader who asked for less movement is the exact case that setting exists for, so it jumps instead and arrives in the same place. The position is read once on mount as well as on every scroll, so a page restored halfway down — a back navigation, an anchor in the URL — has the button already there.PlStat. One figure, and what has happened to it. A row of these is the top of every dashboard, and the whole of what makes them worth a component rather than three<div>s is the change: a number on its own says what things are, and a number with a movement beside it says whether that is going anywhere.The colour of that movement is decided by
improvesWhenand not by the sign, which is the one thing a naive version of this gets wrong — churn going up is not good news, and a green arrow on it is a dashboard lying to somebody.valuetakes a node rather than a number, deliberately: how a figure is written — the currency, the grouping, the decimals, the locale — is the page's decision andIntl.NumberFormatalready makes it, so a component that took a number would have to guess at all four. The arrow isaria-hiddenand the sign is in the text, so colour is never the only thing carrying the direction.A
PlPopconfirm's two buttons wrap. They sat in a row that could not break, so a pair of labels translated into anything longer than "Confirm" and "Cancel" ran off the 280px sheet. Found while porting the component to Flutter, where the same arrangement is a layout error rather than a quiet overflow.
Fixed
dir="rtl"turned the layout over and left the behaviour behind. Base UI reads the writing direction from a React context and from nowhere else: with noDirectionProviderin the tree itsuseDirection()answersltrhowever the document is written. That decides aPlSlider's arrow keys, the way ←/→ walk aPlTabs, aPlMenubaror aPlRadioGroup, and which physical edge a popup's logicalalignresolves to — so a right-to-left page that set the attribute and stopped looked right and behaved left to right. Proved with a test before it was believed:ArrowRighton a slider at 40 answered 45 in both directions.PlassProvidernow renders that context, and takes the answer off the document rather than from a prop —getComputedStyle(document.documentElement).direction, watched with oneMutationObserverfor the whole page. A second place to declare the direction is a second place to get it wrong, and the page has already said it. The newdirectionprop is only for the case the document cannot answer: a subtree that runs the other way round from the page it is in, or a server that knows before there is a document to ask. It merges through nested providers like every other default.The cost is +0.3 kB gzipped on the everything-imported scenario and nothing at all on the four narrower ones, because the provider is only ever reached through the barrel.
A
PlAnimateMarqueedelivered its words backwards under RTL. The strip's flex track had already reversed on its own, and the animation had not — there is no logicaltranslate, so the keyframe was still travelling towards the physical left and running the content off the wrong edge. The sign is now a custom property that[dir='rtl']flips, which makes it the one right-to-left rule in the library that lives in the stylesheet; the test for it loads the shipped sheet, because no component test loads CSS.Three components ran the wrong way under RTL. The library has always been written in logical properties, and that turned out to be the easy half — what nobody had was a test, so the three places the rule had been broken had been broken quietly. A
PlPaneshandle nudged with the arrow keys moved the boundary away from the key it was pressed with: the drag path read the writing direction and the keyboard path did not, which made the component page's own claim that "the arrow keys follow the writing direction, exactly as a drag does" false. APlSwitch's thumb travelled onleft, so under RTL it sat at the wrong end of its track and moved the wrong way — every platform's own switch puts off at the inline start, which is the right-hand end. And aPlCard'sheaderActionwas pushed out withml-auto, landing it on the left of a right-to-left header.PlDivider's vertical rule moved toborder-inline-startwith them; it drew identically either way, and now it is spelled the way everything else is.The test that found them is
test/package/rtl.test.tsx, and it is a contract test rather than a component one for the reason the contract is: RTL is not a feature any component has, it is a rule every component follows, and it breaks one component at a time in a class name nobody looked at twice. It drives a realdir="rtl"document for the parts that are decided in JavaScript, and reads every component's source for a physical direction utility that is not on a short list of deliberate exceptions — each of which is a place where the thing being measured is physical too (offsetLeftfor a moving indicator, Base UI's owndata-sidefor a tooltip arrow,PlassSidefor a drawer's edge). Pairing a logical property with a physical measurement is what would actually break the direction.
Documentation
- Right to left is a page. The support was there and undocumented, which is nearly the same as not having it: a reader evaluating the library had no way to find out. It says what the one-attribute setup is, what flips and what deliberately does not, and names the three places the direction is read in JavaScript and why each one has to be.
1.2.0 (2026-08-30)
Added
Every component that draws something now takes a
classNameand astyle. Six did not, and each for its own reason rather than by decision:PlCommandPalette's prop type is closed because it renders a portalled dialog rather than an element in the caller's tree,PlMenubarMenuandPlNavigationMenuItemdescribe a row that their parent draws,PlMenuGroupandPlMenuRadioGrouphad the class and not the style, and a toast has no element to put either on — it is raised from a click handler. The first four take both on the element a reader would point at: the palette's sheet, the word on the bar, the word in the row. A toast takes them in the samedataits colour and its variant already travel in, sotoast.add({ title, className })reaches the one message rather than every message the provider will ever show. In all six the class joins the component's own rather than replacing them, and the style is applied over the custom properties the component wrote, which is the arrangement the rest of the library already had.The
--plass-*tokens are a typed channel. Every one of them was already settable in an inlinestyle— they are ordinary custom properties, and an inline declaration beats every class there is — but a'--plass-radius-md'written into astyleobject was a TypeScript error before it was anything else, because React'sCSSPropertieshas no index signature. Importing the package now widens it to accept any--plass-*key and nothing else, so a typo in some other custom property is still a typo. This is the channel that matters most, and not as a convenience: the library writes its edge, its shadow, its focus ring and its fill as Tailwind arbitrary properties, Tailwind sorts those last in the generated stylesheet, and an ordinary utility appended after one loses on order whatever it says — while the token underneath it does not. It also cascades, which aclassNamecannot: one declaration on a wrapping<div>and every Plass component inside it answers.PlassTokennames all 108 of them andPlassTokensis the stricter form for a set of overrides written once, where a name that is not a token fails to compile. A new package test readsstyles.cssand the type as text and fails if either grows a token the other does not have.A field's four parts are reachable. Twelve labelled controls —
PlTextField,PlSelect,PlCombobox,PlNumberField,PlOtpField,PlCheckbox,PlSwitch,PlRadioGroupand the four pickers — put aclassNameon the stack: the wrapper holding the label, the control and the two lines of text under it. That is the right element to position and size a field with, and the wrong one for everything else a caller reaches for, which until now had nowhere to go.classNamesnames the four parts inside it:label,control,descriptionanderror.controlis the one that matters. It is the box aPlTextFieldis typed into, the button aPlSelectopens, the tick, the track, the run of radios, the trigger a picker's calendar hangs off — the part a reader acts on, and the part almost every override was aimed at. The other three are named because a field that has a label at all has three pieces of text with three different jobs, and "the red one under the box" is not addressable any other way. APlRadioinside a group is deliberately not in the list: it is a row rather than a field, and its ownclassNamealready lands on it.A portalled surface's backdrop is reachable.
PlModal,PlDrawer,PlOverlayandPlCommandPaletteeach draw two elements into the portal, and aclassNamecould only ever land on one of them — the sheet, the panel, the popup, which is what a reader points at and calls the component. The scrim behind it had nothing at all.classNames.backdropis that second element, and it is a second prop rather than a second meaning for the first: a component's own surface keeps one way of being addressed, so there is never a question of which of the two wins. An inlinePlDrawerdraws no scrim and simply has nowhere for the class to go, which is the one case where passing it does nothing.
Documentation
The styling contract is written down. Prop conventions now has the four channels in the order to reach for them, and — the part that was not documented anywhere — where a plain
classNameloses. The order of two classes in an attribute means nothing; what decides is their order in the generated stylesheet, and Tailwind sorts by name, numeric scales ascending and everything else alphabetically. Sotext-lgloses to asize="sm"field'stext-sm,bg-red-500loses to aghostbutton'sbg-transparent,w-autoloses tofullWidth, and nothing at all beats an arbitrary property. The page says so with the measured table rather than with advice, and gives the two ways out: a token, or the!modifier, which always works because the library carries no!importantof its own. Which stylesheet was imported changes the answer too, and that is a table as well.Getting startedoverpromised what Tailwind's sort decides. On theplass-ui/tailwind.csspath it said that aclassNamepassed to a component "sorts correctly against the component's own classes", which reads as the caller's class wins — and it does not. Tailwind sorts utilities by name, so atext-lgis emitted before asize="sm"field'stext-smand loses to it on order. The line now says what the sort actually settles, and points at the table that spells out the rest.
1.1.0 (2026-08-30)
Added
PlAvatarGroup. A stack of avatars, overlapping, with the ones that did not fit as a+n.size,shape,variant,colorandelevationare set once on the group rather than on every avatar — a stack whose fourth face is a size out is not a stack — and an avatar's own prop still wins, which is what marks one of them out from the rest: the person on call, the account that owns the thing. It reaches an avatar through a context rather than by cloning children,internal/avatar-group.tsfollowinginternal/button-group.tsone component over and for the same reason:React.cloneElementstops working the moment a caller wraps one of the faces in aPlTooltipor produces the row from a.map(), which is most of the time.maxis how many faces are drawn andtotalis how many there are altogether, for the common case where the group was handed only the first five out of a hundred and twenty-eight. The one thing here that breaks a rule is the ring: each face carries a 2px outline in--plass-surface, the only opaque edge in the library, and it is not decoration — two circles of similar tone laid over each other have no boundary between them at all and the stack reads as one smeared shape, and a translucent hairline would not help because what is behind it is the other avatar. Drawn in the page's own sheet colour it reads as the hole the near face is cut out of rather than as a line around anything. The avatar page's hand-rolled stack goes with it.PlMenubar. The strip of words at the top of an application — File, Edit, View — each of which opens a menu. 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. APlMenubarMenuhas nosize,colorordensityof its own, because all three belong to the bar — the only place they can be set once and hold for every menu on it — and the rows inside are the samePlMenuItem,PlMenuSeparator,PlMenuGroupandPlMenuSubmenuaPlMenutakes, because it is the same menu. Two sizing decisions are its own: the trigger sits a rung below the control ladder at every step and takes the compact padding track even atdensity="default", because a menu bar is a strip of words rather than a row of buttons andFile Edit Viewsized as controls would make the bar taller than the toolbar it is drawn on. It draws no surface: a menu bar sits on something, and a sheet under a strip that is already on a sheet is two sheets.PlColorPicker. A colour chosen by eye: a saturation square with a hue rail beside it, the arrangement every design tool has settled on because it is the one that puts every colour of a hue within a single movement of the pointer. The decision the whole component turns on is that the panel's state is HSV and it never leaves that model. Through RGB every shade of black is the same colour, so a picker that re-parsed its own output would snap the hue rail to red the moment the pointer reached the bottom of the square; here the model is kept and the string is derived from it, and an incomingvaluere-seeds the model only when it means something different — compared as a colour rather than as a string, because#FF0000and#ff0000are the same colour written twice and a string comparison would re-seed on every render forever.alphaadds a third rail and the two functional notations drop their fourth argument when the colour is opaque, so a caller who never turned it on never seesrgba(…, 1). The default swatches are a plain spectrum plus the greys rather than the library's six families: those are semantic roles, and a picker is asked for a colour rather than for a meaning. The tick on a chosen swatch is black or white by relative luminance, because a fixed white tick disappears on yellow. There is no colour library under any of it — the conversions are a newinternal/color.ts, a hundred lines of arithmetic, which is the entire reason a component that computes colours brings nothing with it.PlCommandPalette. 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. It is not aPlMenu, which is a short list in one place where every row is visible before you look for it, and not aPlCombobox, because what comes back is not a value but something happening. Base UI's Autocomplete owns the list and its Dialog owns the sheet, so the highlight the pointer and the arrow keys share is one mark rather than two, and a reader never has to work out which of two highlighted rows Enter would run. Filtering happens before the list rather than inside the primitive, which is what lets a group heading be drawn from the same array the rows come out of; each command's searchable text is folded once per list rather than once per comparison, because anormalizeon every command on every keystroke is exactly the cost that makes a palette feel slow.keywordsare matched and never drawn.shortcuton a row is displayed and not bound — the application binds it — while the one key the component does bind is its own opener, read with the sameMod-aware vocabularyPlHotKeysdraws, so the cap on the screen and the key that works cannot drift apart.PlFieldset. A group of controls that answer one question together, with a name on it. It draws no surface, and that is the decision the component is: a group of fields is a grouping and not a sheet, and the sheet already exists — put it inside aPlCardor aPlBoxwhen one is wanted. 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 one a component three levels down rendered and never heard of. Two things are undone on the way: the browser's own border, padding and margin, which are nobody's design, andmin-width: min-content, which is what makes a fieldset holding a wide table refuse to shrink. The legend is a<div>pointed at byaria-labelledbyrather than a<legend>— Base UI's call, and the one that makes the group an ordinary flex container, because a rendered<legend>is lifted out of its fieldset's content box by every browser and agapwould put no space under it at all.PlForm. A<form>that knows which of its fields is wrong. On its own, a page ofPlTextFields 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 focuses the first that failed, anderrorsputs a server's answer back on the field it belongs to rather than in a banner at the top, clearing it the moment that field changes.validationModedefaults toonSubmit, which is the only one of the three that does not tell somebody their email is wrong while they are still typing it. It is not a form library: there is no schema, no resolver and no field array here, and a project that has those hands the result toerrors, which is the seam the whole component is built around.Making that work needed one change to the fields themselves: every component that renders a
Field.Errornow renders one either way. It used to appear only when the caller had written anerror, which meant a field marked invalid by the browser's own constraint validation — or by a form'serrors— went red with nothing said. With a caller's message the box is theirs and shows unconditionally; without one it is left empty for Base UI to fill with whatever actually failed.PlCheckbox,PlCombobox,PlNumberField,PlOtpField,PlRadioGroup,PlSelect,PlSwitchandPlTextFieldall changed the same way.PlNavigationMenu. A site's navigation: a row of destinations, some of which open a panel of more of them. The difference from aPlMenuis what the rows are — a menu holds actions, so its rows aremenuitems and the whole thing is a widget that traps the arrow keys; this holds links, so it is a<nav>full of real<a>s, which is what puts them in the link list, on the status bar and in a crawler's index. An item with anhrefand no children is announced as a destination, one with children as something that expands, and that distinction is the whole reason a site nav is not a menu. The row carries no surface at rest: the words are the page's own, and five bordered boxes across the top of a site is a toolbar. Base UI keeps one panel open at a time and resizes it between items rather than closing and reopening, so crossing the row reads as one surface; the panel animates opacity and its own measured size and never slides, because a panel that slid in would drag a page's worth of links across the screen. Atargetother than this tab hasnoopener noreferrermerged into whateverrelwas asked for — the merge, not an override, because the common reason to write arelby hand isnofollow, and spelling it as a replacement would silently take the protection off. That logic moved into a newinternal/link.tsandPlTextLinknow reads it too, so the library has one copy of a security default rather than two that can drift.PlTransfer. Two lists and the arrows between them: everything that could be chosen on one side, everything that has been on the other. It is the shape for a choice that is long — the columns in a report, the permissions on a role, the people on a channel — where aPlComboboxwith forty chips in its field stops being readable and a list of forty checkboxes gives no answer to "what did I actually pick"; below about a dozen options one of those two is the smaller component. The model is one sentence: ticking is not choosing.valueis which side a row is on, the ticks are which rows the next press will move, and keeping them apart is what makes a press a deliberate act rather than a side effect of reading. The order ofitemsis the order both lists show, so a row does not move when it is sent across and back, and moving drops the ticks on what moved and keeps the rest — a row that has arrived is not still waiting to be sent, and a row the filter was hiding was never part of that press.searchablefolds case and combining marks through a newinternal/search.ts, socafefindsCafé; a label that is a node rather than a string is kept, because the alternative is a row that disappears from a filter it could never satisfy.PlToggleandPlToggleGroup. A button that stays down, and a set of them that share one state. The rule the whole component is built around is that off is neutral: aPlButtonat rest is an action waiting to be taken, and a toggle at rest is a state that is currently false, so accent ink on an unpressed toggle would be saying it was on. On, the three materials give the same two answers aPlSegmentedButton's chosen segment gives —solidtakes the family's gradient and the on-fill ink, the other two light the sheet and leave the label in the accent. The elevation does not move with the state, deliberately: "on" is a fact about the thing beside the toggle rather than about how far the key is off the page. The group squares off the corners that face a neighbour exactly as aPlButtonGroupdoes and publishes the same context, so a toggle inside either kind of run picks the set up; it reports an array in both the single and the multiple case, which is the one shape that does not change type whenmultipleis turned on. Base UI ownsaria-pressed, the controlled pair and the roving tab index — which is what makes a toolbar of eight toggles two key presses deep instead of eight.PlSidebar, and thePlSidebarTriggerthat brings it back. A column beside the page's content, and a drawer once the window is too narrow to hold one — two presentations of one panel, exactly asPlDraweris, because a caller should not have to swap components at a breakpoint and because the children then exist once either way rather than being rendered twice into the document for a screen reader to read twice. Which of the two is showing is a media query, and it is answered in CSS for the first paint and in JavaScript from then on: the markup a server sends is the column, so a narrow screen would otherwise draw a full-width sidebar and throw it away a moment later. It is a real<aside>— thecomplementarylandmark — and it is named by default, because a page with two unnamed sidebars is a page offering two regions called "complementary".resizablewrites the dragged width straight onto the element rather than into state, since nothing in the tree depends on the number except one declaration and asetStateper pointer move would re-render every row in the panel; the handle straddles the edge rather than sitting inside it, because a one-pixel hairline is a one-pixel target. The trigger is hidden by the same media query the sidebar collapses at rather than by a piece of state — a button whose presence depended onmatchMediawould be absent from the server's markup and pop into the header a moment later, on every phone, every time — and it renders nothing outside aPlPageLayout, where there is no sidebar it could be talking about.PlFooter. The sheet at the end of a page, and a real<footer>— which at the top level of a document is thecontentinfolandmark, the region a screen reader offers as "the site's own information" and a search engine reads the copyright, the address and the site map out of. It has no slots on purpose, and that is the whole difference between it andPlHeader: a header's three regions are a fixed arrangement worth writing once, and a footer's content is four columns on one site and one line on the next — a component that guessed at the arrangement would be one every second site fights. What it decides is the sheet: the surface, the gutter, the hairline that says the document ended, and whether it stays in reach.positiontherefore defaults tostaticand not to a header'ssticky, because a footer is the end of the document;stickyandfixedare for the bar that has to stay in reach — a form's save row, a cookie notice — and inside aPlPageLayoutthe height afixedone takes out of the flow is reserved rather than left sitting on the last paragraph.dividerrules the top edge, which is the one that faces content.PlHeader. The bar across the top of a page, and a real<header>— which at the top level of a document is thebannerlandmark, and is the whole reason this is a component rather than a row of<div>s. Its three slots are props rather than sub-components forPlCard's reason: the arrangement is fixed and what a caller decides is what goes in each — and that the middle can be centred on the bar's own midline is only possible because the ends are the component's to measure.align="center"gives both ends an equal share rather than centring the middle in the space left over, because a logo one character longer would otherwise move the navigation, which is exactly what a reader notices between two pages of the same site.sizeis a floor and not a height (mdis 64px, a 40px control with 12px either side), so a bar whose content wraps grows and keeps its padding;maxWidthholds the row toPlContainer's ownremladder while the sheet still spans the window. It is not aPlToolbarwith a tag on it: a toolbar is a row of controls anywhere on a screen and takes its height from its padding alone, and none of the floor, the measure, the brand slot or the place in a layout means anything on one. Inside aPlPageLayoutit registers itself so a sidebar that holds its place knows how far down the window to start; outside one the registration goes nowhere and it is simply a bar.PlPageLayout. The skeleton a page is hung on: a header, a footer, one sidebar or two, and the content between them. What it is really for is the landmarks — a page assembled out of<div>s is one undifferentiated region to a screen reader and one undifferentiated blob to a search engine, and the same page built out of<header>,<nav>,<aside>,<main>and<footer>is a page with a table of contents. It draws no surface of its own and contributes exactly one element to the document, plus the<main>and the skip link that jumps to it; it draws no gutter and no measure either, because that isPlContainer's job and a layout that did both would be a second spelling of one idea. The arrangement is flexbox and media queries rather than JavaScript, so it is right in the first frame the browser paints and right in a page with scripting turned off. The only thing measured is how much the two bars take out of the window, and only because a sidebar that holds its place has to start below a header whose height nobody but the header knows — written onto the root as--p-layout-headerand--p-layout-footerplus an-insetfor each, because astickybar takes nothing out of the flow while still sitting across the top and afixedone has to be reserved for. Which of the two a bar is is read off its ownpositionrather than plumbed through a prop.A
PlScrollZoneanswers the wheel. A mouse has one wheel and it points the wrong way for a strip that runs across the box, and what happens when it turns over one is the browser's own business — which is the problem, because it makes the answer depend on which browser the reader is in, and a shelf that scrolls on one machine and sits still on the next is not a control.wheelis on by default and takes only the vertical half of a gesture, and only while the strip has somewhere left to go: a trackpad's two fingers, a tilt wheel and Shift held down already scroll it sideways and are the browser's, and the moment the strip reaches an end the wheel goes back to the page — a shelf that swallowed it at both ends would be a hole a reader scrolls into. The listener is a native one rather than anonWheel, because React attaches its own wheel listener to the root passively and apreventDefaultinside one of those does nothing but log.
Fixed
- A
renderelement's ownhrefsurvivesPlTextLink. The component merged itshrefover whatever came back fromrender, and the documentation said to write the address once, on thePlTextLink. That is exactly wrong for the thingrenderexists to take: a router'sLinkdoes not forward an address, it resolves one — a locale prefix, a base path, a typed route — and hands the anchor the result, which the merge then overwrote with the raw string. A localised app got/pricingon every anchor where its router had worked out/ko/pricing; the click still went to the right page, because the router's own handler was still attached, so what broke silently was every crawler, middle click and copied address. An element carrying anhrefis now left with it, and only an element without one —render={<a />}— is given the component's.
Changed
- A
PlScrollZone's buttons stand beside the strip rather than over it.buttonPlacementnow defaults toinline. An item sliding under a button reads as something being hidden far more often than it reads as depth, and the two scroll buttons are the one thing on a shelf that must never be mistaken for part of what is on it — so the scroller stops where the button starts and an item is cut off at its edge instead.overlayis unchanged and is still what a shelf of pictures wants, where the strip reaching the edge of its box is worth more than seeing the whole of the item a button came to rest on.
1.0.3 (2026-08-29)
Added
PlTextLinktakes astartIcon. Its one icon slot was the mark after the label, and it is not a general one:iconis about the link's destination and has an opinion, drawing the arrow leaving its box for anewTablink unless it is told not to. There was nowhere to put the other kind — a favicon, a file type, a lock — so a caller wrote it intochildrenand paid for the gap by hand.startIconis the plain node the rest of the library spells the same way: nothing is drawn unless something is put there, and it rides at the link's own0.95ema quarter-em from the label, the same as the mark on the other side.PlPaginationcan wear theLinka router brings.getPageHrefturned every number into a real<a href>— which is what a crawler needs and, in a single-page app, a full document load on every press: the router never saw the click, so the whole page was fetched, parsed and booted again to change one number. The row was only usable as links by an app that navigates that way.renderLinkis the same escape hatchPlButton,PlCardandPlTextLinkalready have, shaped for a control that needs one element per page: it is handed the page and its address, already built, so a router's own component needs no second copy ofgetPageHrefinside it.rel="prev"andrel="next"are merged onto whatever comes back, so the two steppers keep saying what they are, and everything else about the row is unchanged — the current page is still a<button>, a modified press is still the browser's.--plass-z-portalmoves every portalled surface at once. A modal, a drawer, a menu, a select's list, a popover, a tooltip and a toast were each painted at a hardcodedz-50— fifteen class strings agreeing on a number chosen for a page nobody had seen. An app arrives with a stacking ladder already built: a fixed header, a cookie bar, a video player, a game field, and50is a guess at where in it a dialog belongs. It is one token now, and one line in a consumer's stylesheet moves the whole set together — together being the point, since moving them apart is how a select opened inside a modal ends up behind it. Declared on a bare:rootand repeated in neither theme block, which is the one deliberate exception to the derived-token rule and rests on it: a theme block re-declares what it carries, so a token living in one would reset an override on every nested element wearing a theme class.
Fixed
PlTablerenders inside a React Server Component. 1.0.1 put'use client'on all 75 component modules and argued that being uniform cost nothing, because outside a server-component graph the directive is inert. That is true everywhere except the one component whose own API is functions: a client boundary cannot be handed one, and everyPlTablecolumn is arendercallback — socolumnscould not be written by the page that fetches the rows, which is the page a table belongs on.Functions cannot be passed directly to Client Componentswas the whole of what a consumer got, from a component that had never been touched. The directive is off it now, and the one hook it had is gone with it: naming the grid meant generating an id, so the drawn caption isaria-hiddenand a real<caption>inside the<table>carries the same words to a screen reader. Nothing moves on screen and nothing changes for a caller on the client — a module with its own'use client'that importsPlTablegets a client component, as it does for anything else it imports, andonRowClickneeded such a module either way.test/package/use-client.test.tsnow asserts the rule rather than a count: a module that reaches for a hook must declare itself, and the ones deliberately kept out of the client graph are listed with what each buys. Only the decidable direction is checked — a component that builds a handler and hands it to a Base UI primitive is across the boundary too, and no regex sees that, so leaving the directive off stays a review decision.
Changed
- A
PlAccordionItem's title and subtitle wrap. They were each held to one line and ellipsed, which is the right rule for a table cell and the wrong one for a fold: an accordion is most often a list of questions, and a question is a sentence. A heading that ran past the header lost its end with no tooltip, notitleattribute and nothing to press for the rest — a fold opens its body, not its heading — and the narrower the screen, the more of the question went. What wrapping costs is a header two lines tall, in the one component whose entire job is to change height.truncateputs both back on one line for the header the old behaviour was right for: a name out of a database sitting beside a control.
Documentation
- The
READMEopens on what Plass is rather than on a word that reads as Material. It led with "a component library with a material rather than a theme" — material meaning the stuff a surface is made of, which is not the reading anyone arriving from a Flutter or an Android background will reach for, and this library has nothing to do with Material. What is there instead is one paragraph describing the design itself: the question every surface answers, the tinted glass and the clear glass that answer it, and the fact that nothing is bevelled, embossed, or moved by a press. It is worded to serve as the library's summary wherever one is needed, and the root and FlutterREADMEs carry it identically. The bullets under it are written from the caller's side — what is already decided, what is learned once, what nobody has to write — and the runtime dependency is a count rather than a name, since the count is what a reader is weighing andpackage.jsonhas the name.
1.0.2 (2026-08-29)
Fixed
- The package has a
READMEon npm. There has only ever been one in this repository and it is at the root — a directorynpm publishdoes not look at frompackages/react— so from the moment the repository became a monorepo in 1.0.0, every release has arrived on npm with nothing under the install line but the fieldspackage.jsonfills in.packages/react/README.mdis now the React quick start, shaped like the Flutter package's own so the two siblings read the same way: install, the stylesheet and the two Tailwind routes, the background a sheet of glass needs behind it, usage, the per-component entry points, the server-component note, dark mode, the component list and the scripts. None of it is new writing — it is what the rootREADMEalready said, moved to where the reader actually is. Links that leave the package are absolute, because npm resolves a relative one against the repository root rather than againstpackages/react. - The npm page's Repository link points at
packages/react.repository.directoryis the field that says a package is one folder of a monorepo rather than the whole of it. Without it npm sends a reader to the repository root — which is now a map to two packages rather than this one's documentation — and resolves a relative link in the publishedREADMEfrom there as well.
Documentation
- The root
READMEis a map to both packages rather than the React quick start with a Flutter paragraph in it. What left is everything a React consumer needs and a repository visitor does not. What is there instead belongs to neither half alone: a packages table carrying both registries, both quick starts and the fact that the two version independently, the shortest possible install and first component for each, a table of what genuinely differs between them — setup, runtime dependencies, tokens, dark mode, handler names — and the background caveat written twice, once in CSS and once in Dart, because it is the one setup step neither package can perform for the caller and the fastest way to conclude the glass is broken. The claim that0.0.1is a preview went with it: both packages are past1.0.0.
Notes
- Documentation and packaging only. No component, type or stylesheet changed.
1.0.1 (2026-08-29)
Changed
distis minified again, and the annotations survive it. Terser strips/* @__PURE__ */unless it is asked not to, which is what made minifying a library a losing trade in 1.0.0 — butformat.preserve_annotationsre-emits every annotation it reads, andkeep_fnameson bothcompressandmanglekeeps the innerfunction PlButtonthat React DevTools names aforwardRefcomponent by. All 189 annotations reachdist, andscripts/minify.mjsfails the build rather than the tarball if one of them does not, so the tree shaking 1.0.0 bought is not being spent here: the package is 222 kB packed and 785 kB installed rather than 338 kB and 1.21 MB, while every scenarionpm run sizemeasures moves by less than 0.3% — a consumer's own minifier had already done that work.
Added
- Every component ships
'use client', so the package works inside a React Server Component graph. Next.js's App Router is where nearly everyone meets one, and until nowimport { PlButton } from 'plass-ui'in apage.tsxfailed ati.createContext is not a function— a build error in somebody else's app, thrown by a package that builds, tests and documents cleanly here. All 75 component modules carry the directive and the barrels deliberately do not: the boundary is per-module, and a barrel that carried it would drag the library across it whether or not the component being imported needed to be. It is on every component rather than on the handful that would strictly need it today, because outside a server-component graph a module-level directive is inert — Vite, Remix, React Router, Astro and a plaintscbuild all ignore it — so the cost of being uniform is nothing and the cost of guessing is a component that works everywhere except the one framework most people are using. Nothing else changes for a consumer: notranspilePackages, nonext.configentry, no provider. What the directive cannot do is carry a function across the server boundary, which is React's rule for every client component and not this library's — a file passingonClick,onValueChangeorrenderneeds its own'use client'. Two things guard it, because nothing else in this repository would notice one going missing:scripts/minify.mjssetscompress.directives: false, since terser's default is to strip every directive it does not recognise and'use client'is not one it recognises, and fails the build if a file that arrived with the directive leaves without it;test/package/use-client.test.tsasserts the directive on every component module and its absence from every barrel.
1.0.0 (2026-08-29)
Fixed
- A short
PlSpoilerno longer clips its own Reveal button. The cover was absolutely positioned over a box whose height the content alone decided, so a one-line spoiler was shorter than the button it was asking somebody to press. Content and cover now share one grid cell: the row is as tall as whichever of the two needs more, and they stretch to match. - A floating bottom bar shows which destination you are on, and slides the key there.
PlFloatingBottomNavigationpainted its current disc withvar(--p-fill),var(--p-on-solid)andvar(--p-lift)while takingsurfaceSlots— the container slot set, which deliberately carries none of the three. All three resolved to nothing, so the gradient was abackground-imagethe browser dropped and the current destination looked exactly like the four beside it. It readscontrolSlotsnow, and the fill has moved off the disc onto a key that travels: one element measured off whichever disc is current and animated between them, the way aPlSegmentedButton's tile is. Nothing is transformed, so no glyph in the row is resampled while it moves. - The package now loads in Node.
tsccopies a relative specifier through exactly as written, and every import in the source was extensionless — sodist/index.jssaidexport * from './types', which every bundler resolves and Node's ESM loader does not.import { PlButton } from 'plass-ui'threwERR_MODULE_NOT_FOUNDin any environment that is not a bundler: a server render, a React Server Component, a Vitest run that does not inline the dependency. Every relative specifier in the package now carries its.js, andnpm run sizeloads all 47 entry points through Node's own resolver so this cannot come back.
Changed
distis no longer minified, and that is what makes it smaller. Terser was running over every emitted file, and terser strips/* @__PURE__ */— the annotation that tells a bundler a top-level call is safe to drop. Without it, esbuild keeps everyReact.forwardRef, everyReact.createContextand every[...].join(' ')in a file, used or not, because a call it cannot prove pure is a call it must assume does something. So a component that shared a file with another shipped both. Those annotations are now written in the source and survive todist, and the minification that was destroying them turns out to be worth 0–1% gzipped once the consumer's own minifier has run — which it always has.severityIconsisseverityIcon(). Six drawings in a table built at module load is a table a bundler keeps whole or not at all, and it was reaching every component that touchedinternal/icons: a bundle whose only Plass component wasPlButtonstill carried the warning triangle. A function declaration is dropped outright by anything that does not call it.- A table stops being somebody else's table.
PlTablenow writes its cell borders, and the<table>'s owndisplay,width,marginandborder-collapse, as inline styles. Padding and alignment were already inline for the specificity reason; the borders were not, so on any page with prose styles — VitePress, Tailwind Typography, every CSS framework — a host'std { border: 1px solid }drew a full grid of cell rules the design never asked for,table { display: block }stopped the grid filling its sheet, andtable { margin: 20px 0 }pushed it off the corner of the pane. The band behind the column names is gone with it: the header is muted, semibold text over a firmer rule, and rows are scored with a hairline. A filled strip across the top of a grid is the fastest way to make data look like chrome.stickyHeaderkeeps a fill, because there the fill is doing a job — rows pass directly underneath it. - Three tokens for the marks a sheet makes on itself:
--plass-divider,--plass-stripeand--plass-track.--plass-glass-lineis white light on a cut edge and reads because the page wash is behind it; turned inward — between two rows, under a switch's thumb, across every other row of a table — it is white on white, which is why a light-modePlCard,PlListorPlTablehad no visible structure of its own. All three are neutral inks.PlCard's dividers,PlList's rules,PlAccordion's folds andPlTable's rows now share one line. - A switch you can find when it is off. The off track is
--plass-trackrather than the glass at its most opaque, and it has lost its inset shadow, its hairline and the drop shadow under the thumb: a white pill with a white thumb in it is invisible on a light page, and where it was visible — in the dark — a recessed slot under a domed thumb was a moulded rocker, which is the one picture this design language exists not to draw. The thumb is white in both themes. Dropping the track's border also squared the thumb's travel, which had been 2px from the left of the track and 4px from the right at every size. - The focus ring is flush.
outline-offsetis0on every control in both packages, and the strays at1pxand2pxare gone. A ring held off a control that draws an edge of its own reads as three concentric rectangles round one object. The exception is a control something else clips — a tab, a segment, a table row, an accordion header on a scored pane — which takes-2pxso the ring is not sliced off at the container's edge. Every one of them now comes fromfocusRingClasses/focusRingInsetClassesrather than being written out per component. - A field is drawn with the same hairline as the tick beside it.
--plass-borderrather than--plass-glass-line, which aPlCheckbox, aPlRadioand aPlTabsrail already used for the reason it applies here too: a field is very often on a card rather than on the page wash, and a white line round a near-white box on a white card is a field whose shape cannot be seen. - A radio's dot lands on whole pixels. The diameters were already whole numbers; the offsets were not — a 7px disc inside an 18px ring with a 1px edge sits 4.5px from every side, and a circle antialiased at half coverage on all four sides reads as though it has drifted up and to the left. Every dot now has the same parity as the ring's content box, and the line box a tick shares with its label is a whole number too, which is what stops the ring itself from starting at a fraction of a pixel.
- A slider's rail is the same groove a switch's off state is, and no longer the well. A field is a box you look into; a rail is a line you look along, and the part of it that matters is the part with nothing on it — which a white-on-white groove does not have.
- An accordion's body has padding above it. The header's padding buys room around the title; a body starting at the header band's bottom edge put its first line half a leading under the subtitle, so the heading and the paragraph explaining it read as one run of text broken by a colour change.
- The repository is a monorepo. What was the root is now
packages/react, and it is installed and run from there —cd packages/react && npm install. The documentation site has its ownpackage.jsonand runs fromdocs/. Nothing about the published npm package changed.
Added
PlAnimateTyping. Text appearing one character at a time, and it costs both kinds of reader nothing. The whole string is in the document from the first frame in a clipped box, which a screen reader reads once rather than being made to sit through the performance; what animates is a visible copy that isaria-hidden, and the box is not laid out from the characters that have arrived, so the text around it does not reflow on every frame. The advance is by grapheme and not by code point —Intl.Segmenterknows where the boundaries are, and a typewriter that advanced by code points would spend four frames assembling an emoji out of parts that mean nothing on their own.speedis characters per second and is the default unit, because a long paragraph and a short one should be typed at the same pace rather than in the same time;duration, if given, is the time for the whole string and overrides it.repeat,holdanderaseare what make it a loop — type, hold, delete, type again — and withouterasea repeat clears in one frame, which is right for a line being replaced rather than rewritten. Only text is typed: an element among the children contributes its text and nothing about its markup, because there is no honest way to reveal half of a link.PlAnimateHeadline. One line replacing the one above it, on a timer. Every line sits in the same grid cell, so the box is as tall as the longest of them from the first frame and never resizes as the reel turns — which is the whole difficulty with this effect, and the reason the lines that are not showing keep their space withvisibilityrather than being taken out of the layout. It is deliberately not a ticker: a line comes up, it stops, and it is held long enough to read, withintervalcounted from the moment a line arrives rather than from the start of the cycle, so raisingdurationdoes not quietly eat the reading time. Passingindexmakes it controlled and stops the internal timer outright — a controlled headline is somebody else's clock, and a second one running underneath it would fight for the same state. What it is not for is content a reader has to see: there is no guarantee anyone is looking during the two seconds a line is up, and a screen reader is given whichever line happens to be showing rather than the set.PlAnimateMarquee. Content scrolling steadily past, forever. The content is laid down twice and each copy travels exactly its own length plus the gap, so the moment the first copy has left the second is standing precisely where it began — no seam, no jump and no frame where the strip is empty. None of that depends on measuring anything: a percentagetranslateresolves against the element's own box. What is measured is the speed, and that is the prop:speedis pixels per second rather than a duration, so a strip of four logos and a strip of forty move at a reader's pace instead of the long one becoming a blur, and the gap is read back off the computed style rather than parsed out of the prop, because'2rem'is only a number once a font size has resolved.pauseOnHoveris on by default and is not decoration — content moving past a pointer cannot be clicked reliably. Only the first copy is read out; the rest carryaria-hidden.PlAnimateLighting. A light travelling around the outside of something, and the only way this library has of saying here without also saying and it moved. The light is behind the content rather than on it —z-index: -1inside an isolated stacking context — so what a reader sees is a glow escaping from under the edges, nothing inside is altered or overlaid, and it works on aPlCardor aPlButtonwithout touching how either is drawn. The arc turns between the two ends of the family as it travels, which is the same rule every filled surface follows: a flat coloured arc would be the one place in the library where a coloured surface is paint. What actually moves is thefromangle of a conic gradient, which is only animatable because the angle is a registered custom property with a type — rotating the pseudo-element instead swings its corners out past the element on every quarter turn.sizehas to agree with the radius of what is inside it, because the glow follows the wrapper's own corners.PlAnimateAppear. A list of things settling into place one after another, and the effect belongs to the set rather than to any one item: each child takes the same short drift and fade, held back by its position, so a reader's eye is walked down the list in the order it should be read. The animation is written onto the children themselves rather than onto wrappers around them — a row of<li>s stays a row of<li>s, a grid's cells stay its direct children, and nothing about the layout changes because the list is being animated. Whatever class and style a child already had are kept beside the ones this adds; only a bare string, which has no element to write onto, gets a<span>. The stagger counts children and not leaves, so eight children are eight steps and one child holding eight things is one step — which is also how to opt part of a list out.distanceis deliberately short: this is a settling, not an entrance from off screen, and a long travel over a list of eight turns the whole block into something moving.PlAnimateBlink. Content pulsing between full opacity and a floor, on a symmetric cycle — full, faint, full — so however many times it runs it ends where it started. A blink that finished halfway would leave the element permanently half drawn, which reads as a rendering fault rather than as an effect that ended.repeatdefaults to'infinite'here and to1on every other effect, because a single blink is a flicker and nobody asks for a flicker.minis the floor, and it is worth raising for anything that has to stay readable while it pulses. Two things it is honest about: something that never stops moving in the corner of a page being read is the one kind of motion the rest of this library refuses, and a reader with a reduced-motion preference sees none of it — sominis a dimming and never the only thing carrying the message.PlAnimateRotate. Content turning about a point, with two angles rather than one — which is what lets a single component be both effects a rotation is ever used for.fromalone is an arrival: something swings into place and stops.from={0} to={360}withrepeat="infinite"andeasing="linear"is a spin that never lands, which is what a badge, a loading mark or a decorative glyph wants, and it needsfade={false}because a fade that repeats reads as flickering.originmakes the difference between a wheel and a hinge. It is not for text: a rotated word is resampled along its whole length, which is exactly what the rule against transforming a control exists to prevent — rotation is the one movement the design language allows on a glyph without argument, and a chevron is turned rather than redrawn all over the library.PlAnimateSlide. Content travelling in from one edge, and the default distance is the element's own size — so it starts exactly out of frame and arrives without ever having been half drawn somewhere it does not belong. Put it in a box withoverflow: hiddenand the effect is a panel appearing from behind that box's edge; give it twenty pixels instead and it is a different gesture altogether, a nudge that says something changed rather than an entrance.fromis physical —top,right,bottom,left— asPlassSideis everywhere in the library, because a panel coming down from the top comes from the top in every writing direction. Nothing around it reflows while it runs: this is atranslateand not a change of layout.PlAnimateZoom. Content arriving from the middle of where it will end up — the same arithmetic asPlAnimateGrowat more than twice the distance, and always about the centre. That is the whole difference: a grow unfolds from somewhere, a zoom comes at you, which makes it the effect for the one thing on a screen that is meant to interrupt — a confirmation, a result, a figure that has just landed. There is deliberately noorigin: a zoom anchored to a corner is a grow, and the library does not offer two spellings of one idea. Above1it arrives oversized and settles back, which is the other reading of the same gesture.PlAnimateGrow. Content unfolding from a point. What separates it from a zoom isoriginand how far it travels: a grow starts close to its final size and can be anchored to any edge, so it reads as something opening out of the thing beside it — a panel out of a toolbar, a card out of the row it belongs to.transform-origingoverns the standalonescaleproperty too, which is what lets the effect stay off thetransformshorthand entirely and a caller's own transform on the same element survive. Short travel is not timidity either: a sheet growing from0.8stays recognisably the same sheet the whole way, and the blur behind it is never asked to resolve a surface a fifth of the size it is about to be. It shares one@keyframeswithPlAnimateZoom— they are the same arithmetic at two strengths, and a second identical keyframe would only be a second place to fix a bug.PlAnimateFade, and the machinery the wholePlAnimate*set runs on. Content arriving or leaving on opacity alone — the plainest effect there is, and the one that suits the material: a Plass sheet is defined by what shows through it, and the glass ladder is itself built out of opacity. Everything under it is one idea. An effect is a single@keyframesthat runs from a state written entirely in--p-anim-*custom properties to the element's natural one, sointernal/animate.tsnever generates CSS — it fills slots, exactly ascontrolSlots()does for colour, and for the same reason: Tailwind only sees class names that appear literally in the source. Because the from-state is the keyframe rather than a second class,mode="out"isanimation-direction: reverseand nothing else, and it is held at the end — a faded-out element stays faded out rather than snapping back when the run finishes. Waiting isanimation-play-state: pausedrather than a second class, which is what lets atrigger="visible"element sit on its own first frame instead of being fully drawn and then blinked out the moment it scrolls in. And the ten settings everyPlAnimate*takes —duration,delay,easing,repeat,alternate,paused,trigger,play,once,threshold— are one interface intypes.ts, so adelayof 200 means the same thing on a fade as on a marquee. This is also where the rule against transforms is at its narrowest: a control still never moves, and what moves here is content a caller asked to have moved, on the independenttranslate/scale/rotateproperties rather than the shorthand, so a caller's own transform on the same element survives.PlTabletakes amaxHeight, andstickyHeaderfinally has something to stick in. The cap is on the grid: past it the rows scroll inside the sheet rather than the page growing under them, and a pinned header keeps the column names at the top of what scrolls.stickyHeaderon its own still does nothing, and now says so — aposition: stickyheader in a box as tall as its content has nowhere to stick. Two things had to move for it: thecaptionis a heading above the scrolling box that the<table>points at witharia-labelledby, rather than a<caption>that would slide away and take the table's accessible name with it; and a pinned header's rule is an inset shadow rather than a border, becauseborder-collapse: collapsehands a cell's borders to the table's border grid and that grid does not travel with a sticky cell.PlDateTimePicker. A day and a time in one popup — not a date picker that grew a clock and not a time picker that grew a calendar. The two panels sit side by side at exactly the same height, seven rows of cells each, because the calendar's grid and the clock's columns read the same--p-cellladder: the popup is one rectangle rather than two of different sizes pushed together. The bounds do more work here than anywhere else, and it is the one place this parts company withPlDatePicker:minDateis read at full precision, so a minimum of 09:30 on the 27th leaves the 27th selectable in the calendar and greys out the morning in the clock. That is what a "not before now" rule needs, and a day-granular check can only block the whole of today or allow this morning. Picking a day leaves the clock alone and picking an hour leaves the day alone, because nobody reads a popup in the order it was written. The trigger wears the calendar glyph alone: a control cannot say two things at once.namesubmits a localYYYY-MM-DDTHH:MM.PlTimePicker. A time of day, chosen from columns rather than a dial: "half past nine" is two glances at two columns and "any time at all, on the hour" is a column you never touch, while a clock face is prettier, answers neither faster, and needs atransformto read. The bounds are checked at the granularity of the column being drawn, which is what separates a working time picker from a frustrating one: with aminTimeof 09:30 the hour9stays available — it contains allowed minutes — and the minute column is where00through25grey out. Comparing the whole candidate instead hides the 9 and makes half past nine unreachable.closeOnSelectis false here and true onPlDatePicker, because a day is one answer and a time is two; the footer therefore carries aDone. The value is aDaterather than a string or a count of minutes, andreferenceDate— the day a bare time is written onto — is held still for as long as the picker is mounted, so a popup left open across midnight does not move the value onto a new day. The chosen row is brought into view by settingscrollTopon its own column, neverscrollIntoView, which on the frame the popup opens would scroll the whole page.PlDateRangePicker. A span between two days:PlDatePickerwith a second end, two months side by side because a range that crosses a month boundary is the ordinary case. The two panels are one calendar in two halves — the left has no forward stepper, the right has no back stepper, and either header moves both — and they draw no outside days, which is not taste: with both showing six full weeks the 1st of August would appear twice, and two cells with the same name in one popup is ambiguous to a pointer and broken to a screen reader. The band between the ends is drawn as the pointer moves, before the second click lands, and that preview is the whole affordance: without it the first click has no visible consequence. Clicking backwards is the same range typed in the other order, not a mistake to reject. The value is one object rather than a tuple or two props — a range is chosen, cleared and validated as a whole — and the half-finished state lives inside the component, so a controlled caller is never handed a range mid-gesture.presetstakes a function for anything that depends on today. Withname, two hidden inputs of one name arrive asFormData.getAll.PlDatePicker. One day, chosen from a calendar, on aPlTextField's shell wearing a calendar glyph. It adds nothing to your dependency tree and no translation files: everything it does is eitherDatearithmetic orIntl, solocaleis a BCP 47 tag and from it the platform supplies the month and weekday names, AM/PM, which day the week starts on, the order of the header's two buttons and the trigger's own format — a project shipping in twelve languages pays nothing for eleven of them. The only strings left are the ones on the picker's own buttons, and those are onelabelsobject with English defaults. The header is what the component is for: the month name and the year are each a button that opens a grid of its own, so any month of the year on screen is two clicks and any year at all is three, and all three views are the same width and height so switching never resizes the popup. The day grid is always six weeks, for the reasonPlPaginationpins its slot count. There is deliberately no typing into the trigger — parsing a date out of free text cannot be done honestly without a date library. A blocked day carriesaria-disabledrather than the attribute, so it keeps its place in the arrow-key path. Withnamethe hidden input carries a localYYYY-MM-DD, nevertoISOString().PlCombobox. A field you can type into and also choose from. The shell is aPlTextField's wearing a chevron, exactly asPlSelect's trigger is — the three have to be indistinguishable in a form or the form looks assembled rather than designed. What is different is what the text does: it filters the list, and — unlessallowCustomis off — it can become the value itself, offered as its own row at the end of the list rather than committed silently on blur. That row is a real option and not a special case in the key handling, so a click, Enter and the arrow keys all reach it the way every other row is reached. Withmultiplethe chosen values becomePlChips inside the field and the input goes on filtering after each one, so a set of tags is built without the field ever closing — and the field's height becomes a minimum, because chips wrap. Each chip's × is named after its chip. Base UI owns the filtering and its collator, the popup, thecombobox/listboxwiring, arrow-key navigation across both the list and the chips, and the hidden input that makes it submit.PlProgressBox. A row of small glass plates that light up — the third shape, and the one that is about the material rather than about the quantity. A bar and a ring say how much of it is done; a row of plates says this is working, in the library's own vocabulary — the same groove, the same corner, the same gradient — which makes it the right one for a loading state inside a Plass surface, where a foreign grey spinner would look borrowed. With a value the plates fill in order and the leading one partially, so four plates at 30% are one full plate and a fifth of the next rather than a quarter rounded off; each plate is a groove of its own for exactly that reason. Indeterminate, they cycle, and what cycles is the fill's opacity and never its paint:background-imagehas no interpolation between a gradient and nothing, so a plate that swapped its background would snap rather than light. They never move.PlProgressCircular. A ring that fills, for where there is no room for a bar — a table cell, the end of a line. It takesPlProgressLinear's props unchanged except forsize, which measures diameter here rather than thickness, and that is the whole claim the indicators make: one component in three shapes. The arc is the family's gradient and not a flat colour, which costs a<linearGradient>of its own — an SVG stroke cannot be given a CSS one — and is worth it, because a flat ring beside a swept bar is two materials for one idea. The value and the label sit beside the ring rather than inside it: a number in the middle of a dial works at two of the five sizes, and atxsthe ring is fourteen pixels across. Indeterminate, it draws a fixed quarter-arc and turns, which is the same exception the button's spinner already has.PlProgressLinear. A bar that fills — the one indicator that can show how much is left at a glance, because length is the one quantity a reader can compare without counting. The groove is--plass-track, the same neutral ink aPlSlider's rail and aPlSwitch's off state are cut in, so a form holding all three is made of one material rather than three; the segment over it is the family's gradient, which is exactly what the button that submits the form is made of. There is novariant, nodensityand noelevation: an indicator is one material, it has nothing to pad, and it is cut into the surface it sits on the way a groove is — and a groove does not float.valuedefaults tonull, which is the indeterminate case and makes the bar sweep rather than sit empty, because an empty bar is a claim that no progress has been made. Both ends are fully rounded, and that is the one place the rule against pills does not apply: at six pixels tall there is no flat run left to protect.PlButtonGroup. A run of buttons that belong together. Two things happen and only one of them is visual: the corners that face a neighbour are squared off, andvariant,size,color,density,elevationanddisabledare stated once for the set rather than repeated on every button — a group where one button is a size out is the failure it exists to prevent. The axes have no defaults of their own, so an axis the group does not state is one each button falls back to its own default on, and a button that states one itself still wins. Inheritance travels by context rather than bycloneElement, so it reaches a button wrapped in a tooltip, a fragment or a.map().glassis the only variant that overlaps its keys by a pixel, because it is the only one that draws an edge and two hairlines meeting is a seam twice the weight of every other line on the page. The buttons stay realPlButtons: this is not a segmented control, it has no value and it manages no selection — that isPlSegmentedButton, which carries the roving focus and theradiogroupsemantics that go with it.PlToolbar. A bar of controls — an application header, a page's action row, the strip along the bottom of an editor. Three slots and a row:startandendare pinned to their ends andchildrentakes what is left, which is the arrangement every toolbar has ever had. It takes no height: a toolbar is as tall as the controls in it plus thesize/densitypadding every other surface uses, sodensity="compact"gives the dense bar without a second prop meaning the same thing. And it has norole="toolbar"— that role is a promise about keyboard behaviour, and a bar that claims it without implementing it is worse for a keyboard reader than one that never claimed anything.PlSpoiler. Content that is covered until somebody asks for it. The cover is a blur rather than a hidden box, and that is the whole design: a reader can see that there is something there and roughly how much of it, and cannot read it by accident. Blur alone is not cover — it leaves a paragraph's colour and rhythm and a blurred face is still a face — so a wash of the page's own surface goes over the top, which also gives the button something to stand on. While it is covered the content isinert: not tabbable, not readable by a screen reader, and not selectable, because a spoiler that could be defeated by Ctrl-A is not one.PlPopover. A sheet that opens beside the thing that opened it — the middle of the three floating surfaces. APlTooltipis a note about something else and nothing in it can be reached; aPlModaltakes the page away until it is answered; a popover stays up until it is dismissed, can be entered with the pointer or the keyboard, and leaves the page behind it working. That last part is whatmodal={false}says by default, and it is what makes a rename or a filter belong here rather than in a modal that would have taken the page to ask one question. Novariantand noelevation: a popup that had to be asked for has already answered what those ask.PlPill. A floating lozenge holding a small amount of live information — a recording that is running, an upload that is climbing. It is the one stadium in the library, and the exception the radius rule is drawn against: every control is held short of the 50% that would make it a pill because the flat run along its edge is what reads as a sheet with the corners cut, and this is not a sheet lying on the page. The radius is pinned to the row rather than written asrounded-full, so a pill that has grown a second line keeps the corner it always had.detailsopens to a measured height with nothing transformed.PlDrawer. A panel attached to one edge of the window, and two things in one component because they are the same panel:overlayis the drawer you open — a scrim, a focus trap, Escape — andinlineis the drawer that is simply part of the page. They are not two components precisely so that a sidebar becoming a hamburger at a breakpoint is one prop. There is novariantand noelevation: the three materials answer how much a surface asserts itself against the page, and a panel that has taken an edge of the window has answered it. Nothing slides — a drawer that slid in would drag its own text across the screen, which is the case the no-transform rule was written for.PlCollapsible. One section that folds, standing on its own — the same fold aPlAccordionis a set of, with nothing to coordinate with, so what it needs is anopenof its own rather than a place in somebody's list. The panel's height is animated and that is not an exception to the no-transform rule: nothing is transformed, no text is resampled, and the panel is a window opening onto content that does not move.hiddenUntilFoundis the one worth knowing about — a closed fold the browser's own find-in-page can search and open.PlCarousel. A strip of slides, one of which is in view. It is a scroll container with snap points and nothing else, which is where everything good about it comes from: swiping and trackpad dragging are the browser's own, the strip runs the other way under RTL without being told, and nothing is transformed — so the no-transform rule holds here for free rather than by exception.autoPlayis off by default and stays off under reduced motion, pauses on hover and on focus anywhere inside, stops in a background tab, and silences its own live region while it runs.PlBox. The plainest surface in the library: a sheet of glass with content on it, and nothing else claimed. Everything structural belongs toPlCard, which is a box with those sections laid out on it — this is what most of a screen actually groups, which has no heading at all.sizeis the one place in the library where the word means something other than a height: a box is as tall as what it holds and its children bring their own typography, sosizeis the size of the sheet — its radius and its padding.PlScrollZone. A strip of anything — cards, chips, avatars, thumbnails — laid out in one direction and scrolled in it, in as manylinesas you ask for. The mechanism is an ordinary scroll container and everything on top of it is a way of driving one, so swiping, two-finger dragging, the wheel, the arrow keys and the scrollbar are all the browser's own and none of them is intercepted. Nothing is transformed: a scroll offset needs no exception to the no-transform rule, and it is also what makes the strip run the other way under RTL without being told. The two buttons are realPlIconButtons with real names, and inholdmode they answer Enter and Space the same way they answer a press.- An entry point per component, for the builds that cannot take a barrel apart — and for server rendering, where the barrel is not a bundling question at all but 63 modules and their dependencies loaded before the first component is used.
import { PlButton } from 'plass-ui/button'bundles to the same bytes as the barrel does (5.0 kB gzipped either way, esbuild), and loads in 14 ms against the barrel's 230 ms under Node. - The Tailwind scan, published in pieces.
plass-ui/tailwind.cssregisters all 63 components, because Tailwind scans files rather than imports and nothing in a build connectsimport { PlButton }to the classesPlSelect.jsspells out.plass-ui/css/base.cssplus oneplass-ui/css/<component>.cssper component registers only what a project uses — about 5 kB gzipped smaller for a handful of components, and still one Tailwind pass, so the utilities keep Tailwind's own order.plass-ui/tokens.cssis the token sheet with no scan of its own, for a project that would rather write the@sourcelines itself. - A bundle-size budget,
npm run size. It bundlesdist/for real against fixed scenarios — one component, five, all 63 — with React external and@base-ui/reactcounted as ours, and fails the build when a change makes a consumer's bundle bigger.bundlephobiaandnpm packcannot see a tree-shaking regression; a package can double a consumer's bundle without its tarball changing by a byte. - A Flutter package,
plass_ui, underpackages/flutter. Same design language, same vocabulary, same numbers, and the same library component for component. See its own changelog for what each of them had to say differently in a language with no CSS. - A framework switch on the documentation site, at the top of the sidebar. It changes the code, the examples and the props table on every page that has something to say for both, and the Flutter previews are the real package compiled for the web rather than a stand-in.
0.0.1 (2026-08-24)
The first release, and a preview rather than a product. Two components ship; what is actually being released is the shape everything after them will be poured into — the prop vocabulary, the token sheet, the build, the test setup and the documentation site.
Added
- The design language. A Plass surface is a key of tinted glass resting on a clear sheet, and every surface answers one question: is this pressed, or does it hold something? A thing that is pressed is tinted glass — a two-stop gradient at 135° whose ends are the two ends of a colour family at one lightness, a drop shadow tinted with that family, and a bloom of light that follows the pointer across it. A thing that holds something is clear glass — translucent at one of three opacities,
blur(22px) saturate(160%), a white hairline round it, and never dyed, because a sheet holds other people's content and that content arrives with its own colours. - A gradient that turns, not one that shades, and no highlight over the top of it. primary runs indigo to azure, danger vermilion to rose, success green to teal. A gradient that darkens toward one corner is a moulded object catching a lamp and needs a specular highlight to finish the illusion — which is what makes a filled control read as lacquer. A gradient that turns is a pane of tinted glass and needs nothing else, so a
solidcontrol carries no inset edge at all; the hairline belongs toglass, which has a real cut edge. Button.variant(solid·glass·ghost),size,color,densityandelevationoff the shared vocabulary, plusstartIcon,endIcon,loading,readOnly,disabled,fullWidthandrender. Aglassbutton wears the family in its label rather than in its sheet, which is what makescolor="secondary"the quiet neutral button instead of a fourth variant nobody would remember the name of.elevationdefaults to1on Button, against the0a flatter language would use: a control rests on the sheet rather than lying flush with it. Hover adds a level and pressing removes one, so a default button presses down onto the sheet under the finger and a raised one comes back to where it was. The ladder itself is neutral, wide and faint — most of a control's shadow is the tint below.TextField. Single- or multi-line, withlabel,descriptionanderroras part of the component rather than three elements wired together by the caller — Base UI'sFieldpoints the label at the control and puts both messages in itsaria-describedby.errorcarries a message and turns the field invalid, which re-points the whole slot family atdangerso the hairline, the ring, the caret and the message all turn over together;invalidandinvalid={false}are the two escape hatches for a form library that owns the validity.solidmeans the deepest glass on a field, not a tinted pane. A gradient under a caret, a text selection and a placeholder is not legible, so asolidTextField is the well —--plass-well, the one shadow in the library that points inward. Same word, one rule underneath it:solidis the heaviest thing a variant can be while still doing its job.- Six colour families, three hand-picked values each.
--plass-{color}-solid,-solid-toand-on-solidare declared once and are the same in both themes — a pane of blue glass is the same pane in a dark room — and everything a component reads (-fill,-tint,-soft,-line,-ring) is computed from them withcolor-mix(). Adding a family is two edits. - Every gradient end clears 4.5:1 against its own label, and none of them clears it by much. Both halves are the constraint: the floor stops a label from being unreadable, and holding a family further above the floor than it has to be is what makes a set of buttons quietly a shade too deep. Every stop lands within 0.15 of exactly 4.5.
warningis the one family with dark ink, because white on amber does not reach 4.5:1 at any lightness worth calling amber. - Tinted shadows, and they are deliberately not part of the elevation ladder.
--plass-{color}-tintis the difference between a button that is blue and a button that is made of blue.elevationsays how far off the page a surface is; the tint says what it is made of, and adangerbutton one level higher is not a redder pane of glass. - Press is light, not paint. A gradient cannot be transitioned, so hover and press are
filter: brightness()— 1.05 up, 0.95 down — with the elevation and the tint moving in the same direction. One duration and one curve, applied identically in both directions. .plass-glow, the interaction light, as real CSS rather than a Tailwind arbitrary variant: a bloom that follows the pointer across a control and a brighter flash on press that lands in0msand drains over ~700ms. The component writes--p-mx/--p-mystraight to the element's inline style onpointermove, so React never re-renders and nothing reflows. On a touch screen — where there is no hover — the press layer is what carries it, following a finger dragged across the button.src/types.ts—PlassSize,PlassColor,PlassDensity,PlassVariant,PlassElevation,PlassOrientation,PlassSide,PlassAlignand thePlassStylePropsbundle. Asizeofmdis 40px on everything, and an idea that already has a name does not get a second one.
Documentation
- The VitePress site, in English and Korean, with every preview a real React island rendering the components from
src/rather than a screenshot: a home page with a live sign-in hero and a full sample screen, the component index, a page each for Button and TextField, and three design pages — design language, colour and prop conventions. docs/public/llms.txt, the whole site flattened for an agent.
Notes
- Node 20.19 or later, React 18 or 19, one runtime dependency (
@base-ui/react). - The tokens use
color-mix()andbackdrop-filter. Wherebackdrop-filteris missing only the blur drops out.