All studies

2026-09 · Navigation

Liquid glass toolbar

One glass surface that changes shape: a bar that opens into a search field, and then into a panel. Five materials, one construction.

At rest

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.

  1. One label, not five. It is the selection indicator, not a tooltip — exactly one item carries it.
  2. ARIA is the state. aria-current on the destinations, aria-expanded on the panel button. component.js writes those two attributes and nothing else.
  3. Nothing slides. The outgoing pill shrinks to a square as the incoming one grows; adjacent, they read as one mass travelling.

The reveal

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.

  1. Indent, not padding. A box cannot be narrower than its own padding, so padded labels left an 11px stripe beside every collapsed icon. An indent disappears with the text and still counts toward the open width.
  2. The input is measured in a length. A text input has no meaningful max-content width, so that one track is a length rather than 1fr.
  3. Pinned from outside. --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.

Activity

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.

  1. The shape changes, not the stack. The panel row goes 0fr to 1fr, its width 0 to 30rem, and the shell re-measures around it.
  2. The corner never moves. --radius-open is pad + hit / 2, concentric with the corner actions; closed, 999px clamps to the same number. Height does all the work.
  3. Activity, not Notifications. Notifications is a promise about delivery. These four rows are a log — and the name keeps the component from being read as an alert centre.
  4. The extended view exposes a material. Four rows of small type over a sheet you can read the page through. Nothing in the panel is theme-aware.

Motion

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
The label track and the pill’s trailing pad. About 60px of travel.
--morph · 300ms
The panel row, the panel width and the corner. About ten times the distance, so it gets its own clock.
--fade · 140ms + 60ms in
The feed’s opacity. The delay is declared in the open state only, so closing snaps the text out instead of leaving it hanging in a collapsed box.
  1. No overshoot. A curve that passes 1 is lovely on a transform; on a width the bar grows past its size and comes back. With the surface centred, that is a wobble, not a bounce.
  2. Two clocks stage it for free. Press Activity above: the label arrives first and the card follows it down.
  3. Cheaper frames. The lozenge lost a nested backdrop-filter — a second snapshot, on the one element that resizes every frame — and the blur came down to 8px. Backdrop blur costs by radius.

Materials

Nothing 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.

  1. A material is a whole palette. Tint, filter, rim, sheen, edge, drop, lozenge, ink, accent. The ink has to move with the tint, so it is not a hue rotation.
  2. Thin, not opaque. The tint came down to 28–44% and the work moved into the filter. Blur is frost: past about 10px the ground stops being a place. You should see the page’s cross-light continue across every sheet.
  3. Crystal is that argument at its end. At 7% it has almost no body, so it is carried by its edges — a rim, a white sheen, and a second hairline inside the bottom, standing in for the refraction CSS cannot do.
  4. Thinning costs contrast. White on moss went 2.3:1 to about 4:1 against the lit pill; crystal’s dark ink sits near 11:1. That is the number to watch if you re-tint this.

Narrow

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.

  1. Scale, do not squeeze. One breakpoint moves only the values the file already derives from — the same component at 40px targets, not a second small one.
  2. A container query, not a media query. The box above is 15rem; the query reads that box, so a bar in a wider column is unaffected. Reading the viewport is wrong the moment this lands in a sidebar. On a phone every stage on this page is inside the query, which is the point.
  3. The label is what goes. There is no room for it, so the reveal stays shut and the lit pill carries the selection alone. The search field is the exception and goes the other way: an input that is not there is not a search, so the other four actions clear out and it opens across the surface.

Decisions

Type
Navigation

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.

  1. One reveal mechanism, two payloads.

    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.

  2. A cluster of four and a trailing one.

    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.

  3. The corner is derived, and it is the one thing the morph does not change.

    --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.

  4. One surface, not two sheets.

    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.

  5. The label is the selection indicator, and ARIA is where the state lives.

    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.

  6. The open state is five variables, declared once.

    --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.

  7. Glass as a recipe, so a theme is a tint swap.

    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.

  8. Thin the tint, and the filter has to take the work.

    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.

  9. Crystal, the one that is actually glass.

    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.

  10. Everything here animates layout, so the savings are elsewhere.

    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