2026-09 · Navigation · Figma · GitHub
One glass surface that changes shape: a bar that opens into a search field, and then into a panel. Six materials, one construction.
Four actions cluster and the fifth is pushed to the end. The gap says the last item is a different kind of thing, before any label explains it.
aria-current on the destinations, aria-expanded on the panel button. component.js writes those two attributes and nothing else.
Every morph in the bar is one grid column going 0fr to 1fr under overflow: hidden. The track holds a label on four items and an input on the fifth, and does not know which.
1fr.
--searching is a handle for callers that cannot synthesise the event. Click the bar and the pin drops — from there :focus-within runs it, with no script at all.
One surface, not two sheets. The bar and the panel live in the same shell, and only that shell carries a filter, a rim and a shadow.
0fr to 1fr, its width 0 to 30rem, and the shell re-measures around it.
--radius-open is pad + hit / 2, concentric with the corner actions; closed, 999px clamps to the same number. Height does all the work.
Everything here animates layout — a track, a width, a radius, a surface measuring itself — so the smoothness had to come from the curve rather than from moving onto a transform.
--reveal · 220ms--morph · 300ms--fade · 140ms + 60ms inNothing below the variable block changes for any of them. All six sit on Origin deliberately: a set is only comparable as the same state six times. Each stands on the ground it was drawn for, because that is the one thing about a sheet of glass that is not in the sheet.
.liquid-glass-toolbar · moss, the defaulton a dark ground
--slateon a dark ground
--basalton a dark ground
--carnelianon a dark ground
--alabasteron a light ground
--crystalon a light ground
saturate has nothing to amplify and the hue rides on the tint’s alpha alone. Blur is frost: past about 10px the ground stops being a place. You should see the page’s cross-light continue across every sheet.
light-dark() reads the page’s own color-scheme, which both pages now declare.
Five 48px targets, the gaps between them and the surface’s own padding need about 264px — but the resting bar also has a label open, and the longest is Versions at 63px, so the real floor is about 400px. The switch is at 25rem, where the split compresses rather than the label clipping.
A floating toolbar on one pane of glass. Four items sit together and a fifth is set apart; exactly one of them carries its label, and it is the one you are on. The search item opens into a field and the log item opens the pane downward into a panel — neither is a second sheet sliding out from underneath, the one surface changes shape, and the corner it does that around never moves.
The set is a working surface rather than an operating system: Origin, search, Versions, Log, Display. Each item is named for what its glyph can actually do — two sliders are display controls, not a settings screen; two drawers are versions of a thing, not a mailbox — so the bar says what it is for before a label opens. It is also why the labelled item is Origin and not Home: a toolbar that floats over work has no front door to return to, it has a point it measures from, and that is what its glyph draws — a dot inside a ring.
It is as dull as it can afford to be, and finding that floor is the point. Glass has no look of its own — a blur over one flat fill is indistinguishable from a translucent rectangle, and the whole component disappears. But what it actually needs is range, not incident: two soft cross-lit ramps with no edges anywhere give the rim something to sit against and the blur something to carry, and an earlier version with hard-cut clay and sage planes turned out to be buying that at the cost of a backdrop that upstaged the thing in front of it. An even ground settles the vertical centring too. The component is centred exactly — 262.5px of frame above and below it in the thumbnail — but a ground whose whole colour mass sat in the lower third read as bottom-heavy, and a thing centred in a bottom-heavy frame looks like it is floating high.
The ground under the component is page scaffolding in both files rather than part of it, which is what lets the two differ at all. That argument is about the stage, and the thumbnail does not get it. Every other study's preview sits on a flat #f3f2ef, and this one arrived in the rail on its own darker, warmer paper — at 0.7 scale beside three near-white neighbours the card read as a different kind of object before it read as a component, which is the opposite of what a thumbnail is for. So preview.html takes the shared paper and demo.html's stage keeps the ramps. The glass survives the trade because the thumbnail is not showing one sheet: it stacks two, and the lower one has its panel open, so there is a 300px plate of tinted glass on that paper with its own rim, sheen and drop shadow. A single sheet at rest on flat paper would be the failure above; an open field and an open panel on it are a component doing something.
The icons are drawn rather than borrowed: a dot inside a ring, a magnifier, two drawers, a ruled ledger, two sliders. The magnifier is the one glyph in a toolbar with no synonym — every other function has several ways to be drawn, so the set is a place to have an opinion; search is not.
Origin was an arched doorway first, then a benchmark triangle. The doorway was drawing the word home rather than the word it sits under, and five were rendered at real size in the bar to replace it: a square on a baseline, three courses of a plinth, a diamond resting on a rule, a circle overshooting one, and a block on a ground plane in axonometric. The plinth read as an align-left control, the circle read as a second magnifier, and the axonometric block turned to mush at 24px. The benchmark won that round for being the only triangle in the set. It was also the only hard corner in a component built entirely of stadiums, and that is what eventually lost it: the glyph was stern where nothing else is. What stands there now is the station mark a survey measures from — a dot inside a ring — which is the idea the benchmark was drawing, without the diagonal. The circle that failed the first round failed as a bare circle beside a magnifier; what separates these two is the centre dot and the absence of a stem, and at 24px in the bar they do not trade. The cost is that the set gives up its only diagonal, so the magnifier's stem is the one oblique left in it.
The panel item is called Log, not Notifications, and the icon is a margin rule with three entries set beside it. Notifications is a promise about delivery — it says these are things that were pushed at you. What the four rows actually are is a record of what was decided: a type pair set, a tint thinned, a spacing rhythm fixed, a radius made proportional. Naming it for the content rather than for the transport also stops the component being read as an alert centre, which is a different thing with different rules about badges and dismissal.
The ledger replaced four bars of uneven height, and the rename is what broke them. Four bars measure a quantity; they were a fair drawing of Activity and they are the wrong drawing of a Log, which records entries rather than levels. Four drafts went up at real size against them: bulleted entries, a spine with commit dots, a spine with branch ticks, and three rules of even length. The commit dots blobbed into their own stem at 24px, the branch ticks read as a fork, and three even rules read as a menu. The bulleted list read cleanly and still lost, because a bulleted list is a list of anything — a playlist, a to-do — where a margin rule with entries beside it is a ledger. It also keeps the vertical stroke the bars were carrying, which matters more than it sounds: with it gone, Versions, Log and Display are three horizontal-line glyphs sitting in a row, and the middle of the bar turns to hatching.
Drawing a set by hand means centring it by hand. The pill centres the icon's box, so a glyph that does not centre in its own 24-unit box is off-centre in every pill it lands in, and it is invisible in the source — the numbers look tidy. Three of the five were out when the set was first drawn: the benchmark and the bars sat 0.70 units low, the magnifier 0.40 low and 0.40 right. The ring and the ledger that replaced two of them were drawn to the same rule and checked the same way — all five now land on 12.00 of 24, and all five are symmetric across it. Measured as the ink bounds plus half a stroke on each side, which is what the eye reads and what getBBox() leaves out.
The four entries are decisions that were taken — a type pair, a tint, a spacing rhythm, a radius — so the thing at the head of each is a drawing of the kind of decision: a three-step ramp whose bars lose weight as they lose length, a disc filled to half, a gap measured between two rules, a corner filleted over the square corner it replaced, left in at 0.42 behind it so the chip shows what the radius did. A face or a bell says only who or what delivered the row; a diagram says what it is about before the line beside it is read, which is the one job something 19px wide can do. The frame moved with the content: a round frame around a technical diagram reads as an avatar with a picture in it, so the chip is a squircle — the plate the specimen is printed on. The class is __chip and the bold lead is __topic, because __avatar and __who in the contract file would be describing a component that no longer exists.
A corner dot is the platform idiom and it is the wrong object on this bar: it is opaque, it is round, and it sits on a surface whose whole argument is that everything is either the material or seen through it. Set under the glyph it reads as part of the item instead, and it is the lozenge's own shape at a fifth of the size — the bar already says current with a stadium, so it can say unread with one too. It is 0.75 of the glyph long, centred on it rather than pinned to a corner, so it travels with the icon instead of being left behind when one collapses to zero width, and it stops a hair short of full strength, because an accent at 1 would be the only thing on the bar not under the surface.
Every morph in the bar is the same grid-template-columns: 0fr -> 1fr column with overflow: hidden over it. What sits in the track is a text label on four of the items and a text input on the fifth, and the track does not know which — that is why the search field and the nav labels read as the same piece of surface moving rather than as two effects. The catch is that a box cannot be narrower than its own padding, so the gap between an icon and its label is text-indent, not padding: an indent is part of the line, disappears with the text, and still counts towards the max-content width the open track measures. Padded, every collapsed item kept an 11px stripe of nothing beside it. The input is the one payload measured in a length instead of fr, because a text input has no max-content worth the name — its intrinsic width comes from the size attribute, which CSS cannot set. An expanded pill is not padded symmetrically either, and it should not be. A collapsed one is — but the icon does not fill its box, so there is about an --icon/9 bearing of free air before the glyph that the text at the other end never gets. Measured on the resting bar: 14.6px before, 12.0px after. --label-trail gives the trailing side that back and a little more, since text reads tighter against a stadium's curve than a roundish glyph does. It is applied in the expanded state only, so the collapsed action stays exactly square, and it moves on the label's clock so the pill does not finish growing after the word inside it has stopped. The same rule caught the panel, in the other axis and much less visibly. Its row collapses to 0fr when closed, but the feed inside carried a padding-bottom, so the closed panel was not 0 tall — it was one padding tall. That strip of dead height sat inside the surface under the bar, which put every pill above the bar's centre and made the outer radius stop nesting round the inner ones. Neither symptom looks like a padding problem. The feed's inset is margin now; margins do not floor a box.
The bar is not five evenly spaced icons. Four sit together and the last is set apart by --split, which is what makes a closed pill read as a toolbar rather than as a row of buttons — the gap says the last item is a different kind of thing, before any label appears to explain it. It is also the component's only slack: it is the first thing to give when the bar is squeezed, so a label opening or a narrow column eats into empty space before it starts clipping text. First is a claim that has to be enforced, though, not just declared with a big shrink factor: flex shrink is proportional, not ordered, and a split with six times the factor still handed a third of an overflow to the item beside it. See the search field below. It gives in the other direction too, and that took a second round to notice. The bar stretches to the surface, and the surface is not always the width the row asked for — the panel has a floor of its own, so opening it can make the surface wider than the bar needs. Without a grow factor that extra piled up past the last action and left the trailing icon stranded 60px short of the edge it is supposed to be pinned to. Growing the gap instead is margin-inline-start: auto with a minimum, and the minimum is the point: auto alone collapses the split entirely whenever the bar is exactly its content width, which is most of the time. It is a pseudo-element rather than a spacer in the markup, ordered between the two groups. One catch, and it cost a round: a flex-basis on a pseudo-element is not counted in the flex container's max-content contribution, so the surface sized itself as if the gap were not there and then the gap took its space back out of the labels. Setting width instead of flex-basis is counted.
--radius-open is --pad + --hit / 2: the pad plus half a pill, which is exactly the radius that runs concentric with the corner actions inside it. Closed, 999px clamps to half the bar's height and lands on the same number. So the surface keeps its corner through the morph and the height does all the work — which is what the reference does too, and it is why the inner pills never look like they are sitting in a box cut to a different curve.
The bar and the panel live inside a single __shell, and it is the only thing carrying a backdrop filter, a rim and a shadow. Opening the panel does not slide a second sheet out from under a pill; the one surface changes shape — border-radius from 999px to 1.375rem, and a panel row from 0fr to 1fr. Width is the part that is not animated and looks like it is: the shell is width: fit-content, and an auto-width box re-measures its children every frame, so a label opening from zero drags the whole pill wider with it. The panel's own width goes 0 -> 24rem for the same reason — it is what the closed shell measures, and at 0 the pill hugs the bar instead of standing at panel width with nothing in it.
Exactly one item carries its label, and it is the one under the lozenge — a label here is not a tooltip. The CSS reads that straight off the attributes the component needs anyway: aria-current="page" on the three destinations, aria-expanded="true" on the panel button, :focus-within on the search field. So component.js writes two attributes and nothing else — it never measures, positions or animates — and the search field needs no script at all. There is no sliding element either: the outgoing pill shrinks back to a square while the incoming one grows, and because they are adjacent in a flex row the highlight reads as one mass travelling.
--open and the :has() that detects a real aria-expanded both set the same five custom properties, and every rule downstream reads those. The alternative is repeating a two-selector prefix at each rule that cares about being open. Only the three places where a variable cannot carry the change — suppressing the other items' tint, their reveals, and the search track's unit — are written as selectors.
Four shadows doing four jobs: a 1px inset rim that separates glass from what is behind it, an inset highlight along the top edge that gives the sheet thickness, a hairline along the inside of the bottom that is the same light coming back off the far face, and a soft drop that lifts it off the page. Two of those are new and so are two filter scalars — see the thinning note below; the bottom edge is 0 0 0 0 transparent and the scalars are 1 unless a material asks. The lit lozenge used to carry a second, weaker backdrop-filter of its own, and it looked marginally better and cost far too much — see the motion note below. It is a translucent tint and a rim now. The hue is carried by the tint alone: the glass is moss, and the rim, the sheen and the ink are warm off-whites pulled a few points the same way, so moving the tint does not send you round re-tuning six other colours. The one saturated value is the accent, clay from across the wheel, and it is spent only on the two marks that mean unread. --light changes nothing below the variable block — tint, rim, sheen and ink — and the same construction comes back in six materials. Moss is the default and has no modifier; --alabaster, --slate, --basalt, --carnelian and --crystal are the others. Named for materials rather than for brightness, which is not decoration: two of the six flip to dark ink, so --light would have been describing part of the set by a property the rest do not share. Alabaster took a round to get right, and the mistake is the useful part. The first version tinted it with the dark theme's own hue, and a green-grey glass sitting on warm paper reads as a cast rather than as a material. Four palettes on the real ground settled it: pale sage read sickly, cool porcelain read clinical against the paper, limestone had the most material presence but muddied the lit pill's edge, and bone kept both the warmth and the crispness. So a light theme is not the dark one turned up — it is a second material of the same construction, and what decides its hue is the ground it will sit on. Its ink is a warm greige rather than a near-black, for the same reason the glass is not white: alabaster is the lightest thing in the component, and ink at full strength on it reads as printed on top of the sheet rather than seen through it. The part worth remembering is what has to move with it. Softening the strong text spends contrast the strong text had going spare — 12.5:1 down to 7.0:1 against this surface — but the muted line is derived from the same colour and had none to spare at 4.1:1, so dropping the ink without taking its alpha up quietly takes the quiet text below where it started. 4.5:1 was that correction computed against the sheet’s own colour, and a sheet is not a colour — it is whatever the ground behind it became. Measured through the glass, on the light plate this material is for, 0.82 read 4.43:1; it is 0.88 and 5.10:1 now, deliberately off the line. The other two are each a whole palette for the same reason, not a hue rotation. Slate wants its saturation kept down, because a blue glass with the saturate pushed up starts tinting what is behind it and reads as a filter rather than a surface. Basalt leans on its rim more than any of them: a near-black sheet has plenty of contrast at its face and almost none at its edge, so without the hairline it stops reading as glass and starts reading as a hole — which the dark ground it now stands on charges for in full. And the accent moves with each — the warm mark that reads on moss is not the warm that reads on a near-black, and carnelian is the one that cannot move by hue at all: its ground is that hue, so the set's amber sits within 0.004 of the sheet's own luminance and disappears. It separates on lightness instead, the same amber walked up until it clears the sheet.
--dim is the quietest text in the component — the log's timestamps and the closed panel's foot — and it sat at 0.46, which on a surface you can see through is not a quiet tone, it is an absent one. Measured through the glass rather than against it: the label is rendered twice, once with color: transparent, so the sample is the real backdrop including everything backdrop-filter did to it. The timestamps came out at 3.87:1 on the ground their material is for. It is 0.78 now — 0.86 on the two that carry dark ink — and every material clears AA on its own ground: moss 10.65, slate 10.65, basalt 12.19, carnelian 7.58, crystal 7.57, alabaster 5.27. The lever not pulled is the interesting half. Thickening the tint or turning the backdrop's brightness down would raise the same numbers by making the sheet less of a sheet, and backdrop brightness barely moves them anyway — 2.89 to 2.87, measured, because what the text sits on is mostly the tint by then. No tint, no opacity and no filter scalar changed here. The ink got louder and the glass stayed glass.
The first version of the set carried its colour in the tint, at 42–64% opacity. That is a coloured surface with a texture underneath it rather than glass: nothing of the page survives the trip through it, and the backdrop filter is only there to keep the texture from being legible. Every material is thinner now — 28–44%, and crystal at 7% — and thinning is not a slider you can turn on its own. Three things had to move with it. The filter grew brightness and contrast. Brightness does the darkening the tint used to do, which is the whole trick for the dark materials: moss at 28% over a light page is a pale green wash, and moss at 28% over a page turned down to 0.56 is a dark sheet you can see the page's cross-light through. It is also better ink: the label went from 2.3:1 against the lit pill to about 4:1, because the surface under it got darker while getting more transparent. Contrast keeps the ground's range from flattening as it is pushed around. The blur came down, 14px to 8px, and this is the counter-intuitive half. Blur is frost, not glass — past about 10px whatever is behind the sheet stops being a place and becomes a wash, and at that point the tint is doing all the work again. Clear glass displaces what is behind it; it does not hide it. And the edges took over what the body gave up. Every rim and sheen went up, the lit lozenge became a rim with a thin body rather than a pale fill — a glaze is a transparent body with a lit edge, and it is now that in all six rather than only in alabaster — and each drop gained a second, tight contact shadow, because one soft shadow lifts an opaque card fine and a thin one still reads as painted on.
At 7% the tint decides nothing and the material is carried entirely by what it does to the light and by its edges. Its brightness goes slightly below 1, which is the single most useful thing on this page: real glass absorbs a little, and a sheet a shade darker than the page with bright edges reads as glass, where a brighter one reads as paint. Every attempt to sell it by adding — more saturation, more contrast, more lift — came back looking like amber or like a white sticker, because on a warm ground those all push toward cream. --edge is the other half: a hairline along the inside of the bottom, the cheapest available stand-in for refraction, which CSS cannot do, and most of what gives a pane this thin any thickness at all. It cost alabaster a round, and the mistake is instructive. Thinning alabaster along with the others put it and crystal in the same place — two pale sheets on warm paper, distinguishable only by their rims — and a set of five with two members doing the same thing is a set of four with a spare. Alabaster went back up to 44% and kept its brightness near 1: it is milk glass, a body you cannot see through, and crystal is a pane you can. What separates them is not how light they are.
A label opening is a track going 0fr -> 1fr, the panel is a row and a width, and the surface is fit-content around all of it — none of that can be moved onto a transform without giving up the thing that makes it read as one surface breathing. So the way to make it smooth is not to animate less, it is to make each frame cheaper and to stop the curve fighting the layout. Three changes, in the order they mattered. The easing lost its overshoot: a curve that passes 1 and settles back is lovely on a transform, and on a width it means the bar grows past its final size and comes back — with the surface centred, the whole thing slides out and in. That is a wobble, and it was most of what read as jumpy. The lozenge lost its nested backdrop-filter: a backdrop filter inside a backdrop-filtered element takes a second snapshot of the backdrop, and it was on the one element that resizes on every frame of every morph. And the blur came down from 20px to 14px, because backdrop blur costs by radius and it re-runs whenever the surface changes size — which here is constantly. It came down again later, to 8px, for a reason that has nothing to do with cost and everything to do with looking like glass; the saving was free. What is left is one clock split in two: --reveal at 220ms for a label, about 60px of travel, and --morph at 300ms for the panel, about ten times that. A single duration cannot serve both — the label feels sluggish at the panel's speed and the panel feels rushed at the label's. Splitting them also stages the compound move for free: open the panel, and the label arrives first with the card following it down.
notes.md — the full record, including what was tried and dropped