2026-09 · Navigation
One glass surface that changes shape: a bar that opens into a search field, and then into a panel. Five 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 five sit on Home deliberately: a set is only comparable as the same state five times.
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, so the real floor is about 390px. The switch is at 25rem.
ref.png is the resting frame — the labelled Home pill over a rendered scene of grey and slate planes. The other two frames, the opened search field and the opened panel, are described above rather than kept, since the decisions they carry are named below.
What is not being captured: the Memoji avatars, the product copy, the cool grey render the glass sits on, and the icon set. The ground in demo.html and preview.html owes the reference nothing: it is the index's own recipe, warm paper with the same two washes at the same strength, so a study sitting in the rail does not arrive louder than the page around it. It is page scaffolding, not part of the component.
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 icons are drawn rather than borrowed, and all but one are deliberately not the reference's: a doorway instead of a house, two drawers instead of a lidded box, four bars instead of a bell, two sliders instead of a cog. The magnifier is the exception, and it is the exception for a reason — it 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.
The panel item is called Activity, not Notifications, and the icon is four bars of uneven height. Notifications is a promise about delivery — it says these are things that were pushed at you. What the four rows actually are is a log: somebody commented, a build finished, a review was asked for, an invoice cleared. 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 bars were picked against four other drafts rendered at real size in the bar — broadcast rings, a clock, a bulleted timeline, a one-sided ripple. Two of them failed on the same thing: an arch, a magnifier, two drawers and two sliders are all rectilinear or horizontal, so a timeline read as more drawers and a clock read as a second magnifier. The bars are the only glyph in the set standing on vertical strokes, which is most of why they are legible at 24px. The heights go up, up, up, then down — a monotonic rise would read as signal strength rather than as something recorded.
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: the arch and the bars sat 0.70 units low, the magnifier 0.40 low and 0.40 right. Measured as the ink bounds plus half a stroke on each side, which is what the eye reads and what getBBox() leaves out.
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 five materials. Moss is the default and has no modifier; --alabaster, --slate, --basalt and --crystal are the others. Named for materials rather than for brightness, which is not decoration: two of the five 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. It ends at 4.5:1, better than it was. 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: against a light ground a near-black sheet has plenty of contrast at its face and none at all at its edge, so without the hairline it stops reading as glass and starts reading as a hole. And the accent moves with each — the warm mark that reads on moss is not the warm that reads on a near-black.
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 five 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