All studies

2026-09 · Navigation · Figma · GitHub

Liquid glass toolbar

One glass surface that changes shape: a bar that opens into a search field, and then into a panel. Six 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.

Log

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. Log, not Notifications. Notifications is a promise about delivery. These four rows are a log of what was decided — 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, each headed by a specimen of the decision it records rather than by a face. 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 Log 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 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.

  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. Carnelian sits at the top of that range because red has the least chroma to spare: the backdrop is neutral paper, so 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.
  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. It is also the only ink that follows the page rather than the sheet: a pane has no body to settle its ink against, so on a dark ground it was a dark sheet wearing dark ink at 1.3:1. light-dark() reads the page’s own color-scheme, which both pages now declare.
  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.
  5. A ground is half the material. Glass has no colour of its own: what it contrasts against is behind it. On this page’s light stage moss reads 2.08:1, slate 2.05 and carnelian 2.03; on its dark one those four clear AA and alabaster falls to 1.73. No one stage carries the set, and the brightness scalar does not rescue it — moss needs 0.56 → 0.22 to clear 4.5:1 on light paper, and carnelian cannot get there at all. So each plate is the ground its material was drawn for, and neither plate follows the page theme: which ground a material wants is a fact about the material.

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

  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

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.

  1. Every row in the panel carries a specimen, not an avatar.

    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.

  2. The unread mark is a rule under the glyph, not a dot on its corner.

    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.

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

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

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

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

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

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

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

  10. Ink opacity is not glass opacity, and the dim tone was paying for the wrong one.

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

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

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

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

Inspiration
x.com/renzobianchi_

notes.md — the full record, including what was tried and dropped