Notes on front-end architecture, design systems, & the web platform — moved here from the old Allons-y Studio blog.
Tuesday, September 29, 2026
Animating a favicon browsers refuse to animate
The favicon on this site is a bourbon tumbler. As you scroll, it either twirls — the drink sloshing against each change of direction — or you take a sip — tipping toward the title, holding a mouthful, and coming back a little emptier before you top it back up.
None of that is supposed to be possible. Browsers do not animate SVG favicons. Whether you try an
<animate>element infavicon.svg, or a CSS animation, or a SMIL timeline, the tab will render the first frame and ignore the rest. The favicon is chrome, not content, and the chrome is not a renderer....So how does this work?
It cheats. Every frame is drawn as an SVG string, rasterized through a canvas to a PNG data URL at build-of-the-page time, and then the
hrefof everylink[rel="icon"]is swapped through those frames on a timer. The browser thinks the icon is changing, because it is. It just has no idea it is watching an animation.The whole thing is about 370 lines in one module, with no dependencies, and it is skipped entirely under `prefers-reduced-motion`. This post is the interesting half: the physics shortcuts, the timing decisions, and the optimizations that keep it from being a silly thing to ship (though let's be honest, it is a little silly).
# The trick is lag, not simulation
The obvious way to animate liquid in a glass is to simulate liquid in a glass. Don't. At the size this renders — 16 pixels, maybe 20 — a fluid solver would be several hundred lines producing a result indistinguishable from what can ultimately be done with only four lines of arithmetic.
Here is the entire physics model: every part of the drink is handed the glass's angle from a moment ago. Not its own velocity, not a spring, not a force. Just the same curve, read late.
One curve, read three times, drawn to scale. The surface trails the glass by 40ms because liquid answers fast; the cube trails by 150ms because mass answers late. Every bit of apparent physics comes out of that offset. In code, that is unglamorous (which is the point):
pages/scripts/favicon-motion.jsJavaScript const twirlFrame = (t) => frame(twirlAngle(t), ice(twirlAngle(t), twirlAngle(t - ICE_LAG)) + liquid(twirlAngle(t - LIQUID_LAG)));The cube also gets a second, subtler cue: it leans by whatever it still owes the glass — the difference between where the glass is and where the cube thinks it is. When the glass snaps back through center, the cube is still tilted the old way for a beat. Nobody consciously notices that. Everybody notices its absence, because without it the cube reads as painted on.
# The drink has to stay level, and it has to (preferably) not spill
Rotating a glass is trivial. Rotating a glass full of something is where it gets really interesting, because the liquid's surface is the one thing in the drawing that must ignore the rotation — it lies horizontal no matter what the glass does.
The first attempt rotated the resting liquid shape backwards by the same angle the glass turned. That works for about eight degrees and then falls apart: the resting shape is a 45-pixel-tall "rectangle" (with a little curve for effect), and once it counter-rotates far enough one of its corners lifts off the bottom of the glass and you can see the floor through your bourbon.
What works is to stop thinking of it as a shape at all. The drink is an oversized rectangle, counter-rotated out of the glass's frame and clipped to it. There is always more rectangle than glass, so there is nothing to run out of.
That leaves one real problem, and it was the part I enjoyed most.
Enter cosine
If you hold the surface at a fixed height and tilt the glass, the glass gains drink. The wedge that appears on the low side is bigger than the wedge that disappears on the high side, and the level visibly climbs through the sip. It looks like the glass is filling itself while you drink from it.
The area of liquid in a tilted container, while the surface still meets both walls, is:
Text area = 2a · d / cos(θ)where
ais the half-width anddis the surface's perpendicular distance from the pivot. Holddconstant and the area grows with the tilt. Scaledby the cosine instead —d = d₀ · cos(θ)— and the cosines cancel. The area is2a · d₀at every angle. (Yes, I absolutely had to look these up!)The drink is a world-horizontal slab clipped to the glass — which is exactly how the code draws it. Only its distance from the pivot changes, and shrinking that by the cosine of the tilt is what stops the glass inventing bourbon. That formula is also what sets the deepest the glass is allowed to tip. It only holds while the surface still touches both walls — past roughly 27° the raised corner runs dry, the trapezoid becomes a triangle, and the maths silently stops describing the picture. The sip tips 24°, with the margin deliberate rather than lucky.
# Timing, part one: realism
Two numbers took the longest to settle on, and neither of them was related to the physics.
The hold. The first sip tipped, touched, and came straight back — 580ms at full tilt. On replay, it read to me as flinching, not drinking. I slowed it to 1280ms, which is more than half the sequence, and that gave us a sip instead of a sniff.
The drift. Adding the longer hold introduced a new problem: the liquid and the cube settle within about 150ms of arriving, and then nothing moves. A frozen frame for over a second does not read as a pause, it reads as a stall — your eye assumes the animation broke. So the glass drifts 1.5° across the hold. It is far too small to consciously see and it is the entire difference between a held breath and a crash.
In truth, stillness in an animation has to be active. If the thing has stopped moving entirely, nobody can tell the difference between a pause and a bug.
# Timing, part two: readability at 16 pixels
Everything described above is invisible if the drawing does not survive the size it is displayed at, and a favicon is displayed at a size where basically nothing survives.
The original mark had the drink filling about a fifth of the glass interior. At 16 pixels, that is a band about two and a half pixels tall — enough to see if you already know it is there, not enough to read as liquid. I raised the resting level to about a third of the interior, which roughly doubled it, and that was the single largest improvement to the whole effect.
It also cost more than it looked like it should, because of this constraint:
The last frame of the fill animation has to be pixel-identical to the static favicon, or the icon visibly jumps the moment the animation ends and the real file is restored.
The animation's frames come from a script. The resting icon comes from a file. Raising the level meant moving both — the script constant, the SVG's path data, and both PNG fallbacks regenerated from the SVG so all three stay the same drawing. Four files that now move together or not at all.
And the tail on that: browsers keep favicons in their own cache, which a normal reload does not clear. Change the artwork without versioning the URL and returning visitors get the animation at the new level and the resting icon at the old one — which looks exactly like an animation bug, and is not one. The icon hrefs carry a
?v=for that reason.# The trade-offs
This is an ornament. An ornament is allowed to cost approximately nothing, so most of the engineering went into making it cheap.
Every frame is retained, so the frame rate is a memory setting
The frames are PNG data URLs held in an array for the life of the page. Three sequences come to about 120 frames. At 30fps it was 147, and the difference is pure retained string. I dropped it to 24fps, which took roughly a fifth off both the rasterizing at load and the memory held afterwards, and at 16 pixels I cannot tell the two apart. I chose that frame rate for bytes, not smoothness.
One canvas, not one per frame
The naive rasterizer allocates a canvas per frame. There is no reason for that — one shared canvas does fine, as long as you clear it:
pages/scripts/favicon-motion.jsJavaScript const rasterise = (svg) => new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { ctx.clearRect(0, 0, SIZE, SIZE); ctx.drawImage(image, 0, 0, SIZE, SIZE); resolve(canvas.toDataURL("image/png")); }; image.onerror = reject; image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; });That
clearRectis not defensive tidiness, it is load-bearing. The mark never covers the full square, so without it every frame composites over the one before and the icon turns into a smear. I checked: remove the clear and 116 of 117 frames come out different.The fill sequence is a lookup table
The best optimization here was noticing something already existed. The page-load fill animates the drink from empty to full — which means it contains a frame for every level in between.
So nothing else ever renders a partly-filled glass. It asks the fill which frame matches the level it wants:
- Draining while the tab is hidden is the fill sequence read backwards.
- Refilling when you come back is the fill played forward from wherever you left off.
- The sip's top-up hands straight to the fill at the frame matching what the mouthful took.
Three behaviors, no new frames, and the ice sinking as the level drops came free — the fill already animated the cube settling as the drink rose, so running it backwards drops the cube accurately without adding an extra line.
You cannot animate a hidden tab, so don't
The most interesting constraint. A favicon is the one part of a page still visible after you switch away, which is the argument for doing something there. The argument against doing it with animation is that hidden tabs have no frame rate to spend: timers clamp to roughly a second, and Chrome cuts them to once a minute after five. A two-second sequence would take nearly a minute of lurching and then stall.
So the background behavior is not an animation at all. The level drops one swallow every 30 seconds, four swallows to empty, and then the timer stops — one timer per swallow, not a polling loop, and all the background work is over inside two minutes. The five-minute throttle never even applies.
Designing for the constraint got a better result than fighting it would have. A glass that is emptier when you come back is a nicer idea than a stuttering 1fps rock, and it costs a rounding error.
A rotation needs more room than you think
This one cost me an afternoon: the glass nearly fills its own viewBox, so rotating it at all pushes a corner outside the frame. The original 8° tilt had been quietly clipping its own corners for months. Everything tilted now goes through a fit pass that scales down only as far as the overflow demands and nudges what is left back inside — and is the identity at 0°, so the resting frame still matches the static file exactly.
Its side effect turned out to be a feature. The nudge slides the glass against the tilt, which converts a pivot at the base into an effective pivot around the middle — which is what tipping a glass toward your mouth actually looks like.
# What it degrades to
- Reduced motion: the whole module is behind
prefers-reduced-motion: no-preference. Nothing renders, nothing rasterizes. - Safari: does not repaint favicons on
hrefchanges, so none of this is visible there. It fails silently and costs one wasted rasterizing pass. - No canvas: guarded, skipped.
- Sizeless SVG: worth knowing if you build something similar — an SVG with only a
viewBoxand nowidth/heightis a long-standing rasterizing hazard outside Chromium. The frames carry explicit dimensions now.
The static favicon is always the real file, and the animation is always additive. If every part of this fails, you have an icon of a glass of bourbon, which was the requirement in the first place.
# Was it worth it
Absolutely not, and I would do it again. It is a tab icon that nobody asked for, that Safari cannot see, that is invisible to anyone with reduced motion on, and that occupies about 250 square pixels of anyone's screen.
But the constraints were genuinely interesting — a rendering target that refuses to render, a frame budget measured in kilobytes of string, a background state with no frames at all — and every one of them turned out to have a cleaner answer than the brute-force version. That is the kind of problem that is fun precisely because the stakes are zero.
Scroll, and mine is having a drink.
Draft
Wednesday, May 13, 2026
When to reach for logical properties (and when not to)
There's a school of thought in front-end circles: logical properties everywhere.
margin-leftis dead, long livemargin-inline-start! Chris Coyier put it plainly in July 2025: "yes, just use logical properties all the time. If you need an answer with zero nuance, there it is." It's an entirely reasonable position with real benefits — consistency, future-proofing for i18n, fewer judgment calls in code review.I've landed somewhere a little different personally; perhaps it's just my love of nuance. The short version: logical properties carry semantic meaning that physical properties don't, and leaning on that meaning makes CSS easier to read later. So I reach for logical properties wherever styles relate to content or content containment, and I keep physical properties for values that are anchored to the viewport, device, or hardware interaction.
Both approaches ship. Neither is wrong. This post is about why the split feels right to me and where the boundary lands in practice.
# What logical properties actually claim
When you write
margin-inline-start: 1rem, you're making a small semantic claim: this margin belongs on the side where reading begins. In English (LTR), that's the left. In Arabic (RTL), that's the right. In traditional Japanese (vertical-rl), that's the top.MDN frames it nicely: logical properties "control layout through logical rather than physical direction and dimension mappings." web.dev's framing is even more concrete: physical properties refer to viewport dimensions ("like a compass rose on a map"); logical properties refer to the edges of a box as they relate to the flow of content.
That distinction is the part I find genuinely useful; it lets the property name carry information about what the value means, not just where it lives.
# The heuristic
A metaphor that's helped me hold this in my head: content is a river, the viewport is a riverbed.
The river moves quickly, finding its way through whatever channel it's given — its direction depends on the terrain. The riverbed shapes that channel, but on a different timescale; it's eroded and reshaped over much longer stretches by the water itself. Both are in motion, but they answer to different forces and respond at different speeds.
Logical properties describe the river: values that adapt as the reader's language and writing mode shifts. Physical properties describe the riverbed: values anchored to the slower-moving facts of the device, the viewport, and the platform conventions users expect.
So before writing a directional property, I ask: does this value follow the reader, or follow the screen?
- Follows the reader (the river) → logical (
margin-inline,padding-block,border-inline-start,inset-block-end) - Follows the screen (the riverbed) → physical (
top,right,bottom,left)
That's the whole rule. The rest is examples.
# Content and content containment: a great fit for logical
This is the river. Anything wrapping prose, form fields, lists, cards, articles — anything where the box exists to hold content that flows in reading order — is where logical properties earn their keep.
A prose container
CSS .article-body { max-inline-size: 65ch; padding-inline: 1.5rem; padding-block: 2rem; margin-inline: auto;}.article-body > * + * { margin-block-start: 1em;}.article-body blockquote { border-inline-start: 4px solid var(--color-accent); padding-inline-start: 1rem; margin-block: 1.5rem;}max-inline-size: 65chsays "the reading measure should be ~65 characters along the reading axis" — true in English, Arabic, and Japanese alike. The blockquote's accent bar stays on the side where lines start. If this site ever gets translated, the typography keeps working without retrofitting.The same component with physical properties
CSS .article-body { max-width: 65ch; padding: 2rem 1.5rem; margin: 0 auto;}.article-body blockquote { border-left: 4px solid var(--color-accent); padding-left: 1rem;}This renders identically in English and is fine if you're only ever shipping LTR. The trade-off shows up later: in an RTL translation the blockquote's accent bar lands on the trailing edge instead of the leading edge, which inverts the visual hierarchy. Logical properties let you skip that retrofit.
A search field
CSS .input-search { padding-inline: 1rem 2.5rem; /* room for the search icon on the trailing edge */ padding-block: 0.5rem; border-inline-start: 3px solid var(--color-border-strong);}The icon sits on the trailing edge of the field — right in English, left in Arabic. The leading border accent stays on the side where text begins. Both flip correctly with
dir="rtl"— no[dir="rtl"]overrides, nortlcssbuild step. Ahmad Shadeed's RTL Styling 101 is the canonical deeper dive if you want one.# Viewport-anchored chrome: where physical still describes what you mean
And this is the riverbed. Some styles aren't really about content flow — they're about the device, the viewport, or the user's hand on a pointer. For these, physical property names map more directly to the intent.
A back-to-top button
CSS .back-to-top { position: fixed; bottom: 1.5rem; right: 1.5rem; inline-size: 3rem; block-size: 3rem; border-radius: 50%;}This button belongs in the bottom-right corner of the viewport, not the trailing-bottom corner of the reading direction. When someone switches their browser to Arabic, they probably don't expect the "back to top" button to migrate to the bottom-left of their screen — convention and muscle memory point to bottom-right regardless of locale.
Notice the mix:
position: fixedandbottom/rightdescribe the viewport anchor, whileinline-size/block-sizedescribe the box dimensions. That's roughly the line: the anchor is physical, the box is content-related.The same button with logical insets
CSS .back-to-top { position: fixed; inset-block-end: 1.5rem; inset-inline-end: 1.5rem; /* ... */}In English this is identical. In Arabic the FAB moves to the opposite corner — which might be exactly what you want for some designs, but is often surprising. This is the case Chris Coyier acknowledged in his own piece: "place this chat widget on the bottom right of the page. The language perhaps doesn't matter here." Whichever you choose, it's worth making the choice deliberately rather than letting the property name decide for you.
A draggable resize handle
CSS .panel-resize-handle { position: absolute; top: 0; bottom: 0; right: 0; width: 4px; cursor: col-resize;}The user's pointer mechanics are physical. The handle sits on the right edge of the panel because that's where their hand expects it for a left-to-right pane layout. If the broader layout mirrors in RTL, the panel might mirror too — but that's a layout-level decision worth making explicitly.
A notification badge
CSS .avatar { position: relative;}.avatar-badge { position: absolute; top: -2px; right: -2px; width: 12px; height: 12px; border-radius: 50%; background: var(--color-danger);}Notification badges are conventionally top-right on avatars across most platforms — iOS, Android, web. That's a visual design convention rather than a reading-flow statement, so physical property names match the intent.
Print styles
CSS @media print { @page { margin-top: 0.75in; margin-bottom: 0.75in; margin-left: 1in; margin-right: 1in; }}Print margins map to physical paper. The binding edge of a sheet isn't a content-flow concept; it's a physical-medium concept. Logical equivalents exist on
@page, but the values you're picking are usually about sheet orientation, not language.# Where it gets fuzzy
Some cases sit on the border. Here's how I tend to resolve them — though reasonable people will land differently.
Icons inside buttons
CSS /* Pagination "next" button — part of reading flow */.btn-next .icon-arrow { margin-inline-start: 0.5rem;}/* Mute icon on a volume slider — UI convention */.volume-control .icon-mute { margin-right: 0.5rem;}The pagination arrow is part of the reading flow — "Next →" should become "→ التالي" in Arabic. The mute icon next to a volume slider is closer to UI chrome convention than to reading order, so physical reads more naturally to me. Other devs reasonably go either way here.
Grid and flex layouts
CSS /* Content-driven layout: sidebar holds related reading, main holds the article */.article-layout { display: grid; grid-template-columns: 1fr 16rem; gap: 2rem;}Grid and flex are already writing-mode-aware —
grid-template-columnsreverses automatically in RTL because the inline axis reverses. The cleanest case: use the default behavior and the layout follows content flow because grid and flex were designed that way.If you specifically don't want a layout to reverse — say, a media-player UI where the timeline scrubber should stay LTR regardless of locale — that's when explicit physical positioning or
direction: ltron the subtree comes in.Box dimensions
CSS /* The image's intrinsic dimensions are physical — it's a 1200×800 photo */.hero-image { width: 100%; max-width: 1200px; height: auto; aspect-ratio: 3 / 2;}/* The card's content-defined dimensions are logical */.card { max-inline-size: 28rem; min-block-size: 12rem;}width/heightfor hardware-anchored or media-intrinsic dimensions;inline-size/block-sizewhen the dimension is defined by content flow. In practice,max-inline-sizeon text containers andwidth/heighton media covers most cases.# The "logical everywhere" position, fairly stated
The strongest version of the always-logical case is worth engaging with on its own terms:
- Consistency reduces cognitive load. One rule is easier to enforce than a per-property judgment call, especially across a team.
- Future-proofing is cheap. Logical properties behave identically to physical ones in default LTR mode, so there's no rendering cost to defaulting to them.
- The boundary really is fuzzy. If teammates disagree about whether a sidebar is content-related or layout-related, you spend time on that instead of shipping.
Jens Oliver Meiert pushed back on Coyier's piece from a different direction — he argues there's no universal requirement to use logical properties, and that for English-only projects the cognitive overhead of always-logical is real.
My preference for the content-vs-chrome split is essentially this: I want the property name to carry information about what the value means. When I read
inset-inline-endsix months later, I want to be confident the value should follow the reading direction. When I readright, I want to be confident the value is anchored to the screen. If the codebase uses logical-everywhere, both properties communicate "directional offset" but neither communicates which kind, and I have to read the surrounding context to find out.That's a small cost per property and a real benefit in maintainability for me. Others reasonably weigh it differently. Both shapes of codebase ship, and both can be excellent.
# The one-screen decision guide
When reviewing CSS, for each directional property I ask:
- Does this value relate to text, content, or a content container? → logical fits well
- Does this value relate to viewport position, device orientation, or hardware interaction? → physical fits well
- Would I want this to flip in RTL or vertical writing modes? → logical if yes, physical if no
- Is this a design convention anchored to a physical screen location (top-right badge, bottom-right FAB, fixed header)? → physical fits the intent
If "what does this value actually mean?" answers as "the side where reading begins," logical reads naturally. If the answer is "the bottom of the screen," physical reads naturally.
# Browser support, briefly
This used to be a real concern; it isn't anymore. CSS logical properties have been Baseline widely available since 2023, and Safari 15 closed the last meaningful gap. Adrian Roselli updated his canonical post in August 2025 to confirm the same. If you're targeting modern evergreen browsers, you can use logical properties freely — the open question is the style one, not the support one.
One small caveat: the
insetshorthand accepts physical-mapped values in the order top/right/bottom/left, so even though the property name reads "logical-ish," the shorthand is physical. For logical offsets, use the individualinset-block-*/inset-inline-*properties.# TL;DR
Logical properties describe content flow. Physical properties describe viewport position. They overlap in default LTR mode, so the choice is mostly about which one matches your intent.
- Reading containers, prose, form fields, cards, lists, content borders, content spacing → logical fits naturally
- Fixed-position chrome, viewport-anchored buttons, notification badges, drag handles, print page margins, media-intrinsic dimensions → physical fits naturally
- When fuzzy, the question I find useful: should this flip with reading direction? Yes → logical. No → physical.
Whichever convention you adopt, pick deliberately and document it. The "logical everywhere" teams ship great code. The "logical for content, physical for chrome" teams ship great code. The unhappy middle is the one where the choice is made property by property without any shared rationale.
And whichever you pick, the upside of thinking about your CSS this way is that you stop worrying about what's just around the riverbend — your styles already know how to follow the water.
# Resources
- CSS logical properties and values — MDN — the spec-aligned reference
- Logical Properties — web.dev — the "compass rose vs. content flow" framing
- CSS Logical Properties — Adrian Roselli — comprehensive cheat sheet, updated Aug 2025
- Digging Into CSS Logical Properties — Ahmad Shadeed — practical RTL-first perspective
- RTL Styling 101 — Ahmad Shadeed — the canonical reference for RTL-aware CSS
- Should we NEVER use non-logical properties? — Chris Coyier — the "logical everywhere" argument, well made
- Should We Never Use Non-Logical Properties? — Jens Oliver Meiert — the pushback
- CSS Logical Properties are cool — don't use them — beeps — older, but the spec-shorthand awkwardness it documents is still partially relevant
- From margin-left to margin-inline — Brett Dorrans — design systems framing
inset— MDN — the one shorthand that mixes physical and logical semantics
- Follows the reader (the river) → logical (
★ Featured
Thursday, March 26, 2026
Introducing envoy: environment setup, handled
Here's what nobody tells you about onboarding: the hardest part isn't the architecture docs, the branching strategy, or even getting the right access. It's
.env.Every project I've ever worked on starts the same way. Copy
.env.exampleto.env. Open Notion, or 1Password, or a Slack thread from six months ago. Track down the values. Paste them in. Repeat. In a monorepo, this ritual scales badly — five packages, five.env.examplefiles, five rounds of the same scavenger hunt. It's not hard work. It's just tedious work, and tedious work is the kind that gets skipped, done wrong, or turned into a half-finished onboarding doc that nobody updates.I built envoy as a way to eliminate it.
# The problem with
.envmanagementLet's be honest about what
.envsetup actually is: a key lookup exercise. You have a template (.env.example) that tells you what keys a project needs, and you have the real values stored somewhere on your machine or in your secrets manager. The only "work" involved is matching those two things together — and yet we do it manually, every time, for every project.There are a few ways this tends to go wrong in practice.
The incomplete setup. A contributor clones the repo, misses a value in the
.env.example, and spends the next hour debugging why a feature isn't working. The error message points nowhere near the actual problem.The stale example file. Someone adds a new required key to the codebase and forgets to document it in
.env.example. Now the example file lies. Every new contributor is set up to fail before they write a single line of code.The monorepo multiplication problem. Package A needs
DATABASE_URL. Package B needsAPI_KEY. Package C needs both plus three others. You're now maintaining five different.envfiles by hand and hoping none of them drift out of sync. In practice, they always drift.The solution to all three is the same: treat
.envsetup as a deterministic process that can be automated, not a manual ritual that depends on tribal knowledge.# How envoy works
The premise is straightforward. You probably already have a
~/.envon your machine — or you will after using envoy for five minutes — with the real values: API keys, database URLs, tokens, secrets. The values that follow you from project to project.envoy reads your
.env.exampleas a template, pulls matching keys from~/.env, and writes a complete.envfile alongside it. Comments, blank lines, and keys you haven't defined yet all survive the process exactly as written.Terminal $ yarn dlx @allons-y/envoy✨ Created /your/project/.envThat's the whole thing. No config files, no network calls, no global state. Just a template and a source of truth, merged together.
Why
~/.envinstead of a secrets manager? The short answer: zero dependencies. envoy works offline, doesn't require an account, and integrates with whatever secrets workflow you already have. Pull values from 1Password or Vault into~/.envas a sync step — that's a separate concern and envoy doesn't try to own it.# The details that matter
Safe by default
envoy will never overwrite an existing
.envwithout your permission. If a.envalready exists, it skips that file and moves on. This is the behavior you want — running envoy repeatedly on an established project should be a no-op, not a destructive operation.When you do want to overwrite — say, you've added keys and need to regenerate — pass
--force:Shell yarn dlx @allons-y/envoy --forceMonorepo-aware
envoy recursively walks your project directory, finds every
.env.exampleit can, and processes each one. It skipsnode_modulesautomatically. You don't have to tell it where your packages live or configure a list of paths — it just finds them.Shell # Finds and processes all of these:# packages/api/.env.example# packages/web/.env.example# packages/workers/.env.exampleNon-destructive key handling
If a key exists in
.env.examplebut not in~/.env, envoy falls back to the example value instead of dropping the key entirely. This matters: you don't want a missing global key to silently produce a broken.env. You want the key present with a placeholder, so the next person knows it needs a real value.Preview before writing
Not sure what envoy is going to write? Run it with
--dry-runfirst:Shell yarn dlx @allons-y/envoy --dry-runYou'll see a full diff of what would be created or changed — nothing touches the filesystem. This is especially useful the first time you run it on an existing monorepo.
# The postinstall hook
This is the highest-value use case by a significant margin. Wire envoy into your project's
postinstallscript:JSON { "scripts": { "postinstall": "envoy" }}Now every contributor who clones the repo and runs
npm installoryarngets a populated.envautomatically. No onboarding doc to write. No values to chase down in Slack. No "why doesn't this work" messages on day one.If your package is published to npm, use pinst to strip the postinstall hook from your tarball. You don't want consumers running envoy when they install your library — only developers working on the project itself should trigger it.
JSON { "scripts": { "postinstall": "envoy", "prepublishOnly": "pinst --disable", "postpublish": "pinst --enable" }}The practical effect: onboarding goes from "follow these twelve steps in the right order" to "clone the repo and run install." That's not a small improvement in developer experience — that's the difference between a smooth first day and a frustrated one.
# The MCP tool
If you're running Claude Desktop or Claude Code, envoy exposes a
copy_envtool through an MCP server. The same options available in the CLI —--force,--dry-run, and a target path — are available directly from your AI tools.Shell # Start the MCP serverenvoy --mcpThis means you can ask Claude to set up your environment as part of a broader workflow — scaffolding a new package, bootstrapping a project, or running a setup checklist — without breaking out to the terminal. The tool validates paths, respects the
--forceflag, and returns the same output you'd see from the CLI.Practical example: You're scaffolding a new package in your monorepo. Claude creates the directory structure, writes the
package.jsonandtsconfig.json, and callscopy_envto generate the.env— all in the same workflow, without manual steps between.# Configuring your
~/.envFor envoy to do its job, you need a
~/.envwith your real values. If you're starting from scratch, the pattern is simple: collect every key you use across all your projects and put the real values here.Shell # ~/.env# Shared databaseDATABASE_URL=postgresql://localhost:5432/dev# API keysSTRIPE_SECRET_KEY=sk_test_...OPENAI_API_KEY=sk-...GITHUB_TOKEN=ghp_...# Internal servicesINTERNAL_API_BASE_URL=http://localhost:3001If you're already using a secrets manager, the recommended pattern is to sync into
~/.envas part of your shell startup or a manual pull command. envoy reads whatever is there — it doesn't care how it got there.# Dos and don'ts
Do
- Add envoy to
postinstallso environment setup happens automatically onnpm installoryarn - Commit
.env.examplewith sensible placeholder values — the fallback behavior depends on those placeholders being meaningful - Use
--dry-runthe first time you run envoy on an existing project - Keep
~/.envas your source of truth for shared values across projects - Use
pinstif your package is published to npm
Don't
- Don't commit real values to
.env.example— that file belongs in source control; treat it like documentation, not secrets - Don't run envoy without
--forceexpecting it to update an existing.env— safe-by-default means it won't - Don't rely on envoy to sync secrets from a remote source — that's a separate concern; handle it at the
~/.envlayer - Don't use envoy as a replacement for proper secrets management in CI — it's a local developer tool, not a deployment pipeline
# Try it
No installation required:
Shell npx @allons-y/envoy --dry-runThe source is small, readable, and fully tested. Apache 2.0.
→ GitHub: github.com/allonsy-studio/envoy
→ npm:@allons-y/envoy# Wrapping up
The best developer tooling solves problems so completely that you stop thinking about them. Environment setup is one of those problems — it's not interesting, it doesn't require expertise, and the only thing it produces (when done manually) is friction. envoy makes it automatic, repeatable, and boring in the best way possible.
Set up
~/.envonce. Add envoy topostinstall. Clone any project. Run install. Done.That's the goal: the setup ritual disappears, and you get straight to the work that matters.
- Add envoy to
Sunday, November 23, 2025
Performance monitoring for web components
Here's what nobody tells you about building web components: you can follow all the best practices, optimize your Shadow DOM, and keep your bundle sizes tiny, but if you're not measuring performance, you're flying blind. Sure, components perform well on your beefy MacBook Pro, but what about on a mid-range Android device over spotty 3G connection?
The good news? There's a browser API specifically designed for this. The User Timing API lets you drop performance markers directly into your component lifecycle and see exactly where time is being spent. No third-party libraries, no complex setup, just widely-available native browser capabilities that integrate beautifully with dev tools and Lighthouse.
This guide will walk you through instrumenting your web components with performance tracking that actually matters; measurements you can act on with insights into the user experience.
# Why performance monitoring matters
Performance isn't just about making things fast, it's about understanding where your components spend their time. These insights help you make informed decisions about where to spend your user's attention. Did that fancy animation you added slow down initial render? By how much? Is your component's first paint happening fast enough that users don't notice a delay? You won't know unless you track the data.
The web can be unforgiving. Users expect instant responses with limited attention to spare; every millisecond counts. Performance marks show up in your dev tools, giving you visibility into what's actually happening when your components hit the DOM.
# Understanding the User Timing API
Before we dive into implementation, let's talk about what the User Timing API actually does. It provides high-precision timestamps that are part of the browser's performance timeline, and it does this through two simple concepts: marks and measures.
Marks are timestamps you place at specific points in your code. Think of them as performance breadcrumbs—they tell you when something happened.
Measures calculate the elapsed time between two marks. They're the answer to "how long did that take?"
The beauty of this API is simplicity. No complex configuration or heavyweight monitoring solutions. You're just saying "mark this moment" and "measure from here to there."
# Core implementation strategy
When instrumenting web components, you want to capture the moments that matter most to users. Here's a few things you could be tracking:
Component registration: When the component is defined and added to the custom elements registry
DOM connection: When the component is first inserted into the document
First render: When the component completes its initial render and becomes visible
Upgrade time: The total time from registration to first render
These measurements tell you the complete story of your component's lifecycle performance. Let's look at how to implement this.
# Implementation patterns
Setting up performance marks
The first step is identifying where to place your marks. For web components, there are natural lifecycle hooks that map perfectly to performance milestones.
When your component connects to the DOM, drop a mark:
JavaScript connectedCallback() { performance.mark(`${this.localName}:connected`); // Trigger initial render this.render();}In this and following examples,
this.localNamerepresents a property on the web component that stores it's tag name, i.e.,custom-button.After your first render completes, drop another. You'll need to track whether this is the first render:
JavaScript render() { // Your rendering logic here this.shadowRoot.innerHTML = ` <button>Click me</button> `; // Mark first render complete if (!this._hasRendered) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); }}For component registration, you'll want to add a mark when you define the custom element:
JavaScript customElements.define('my-component', MyComponent);performance.mark('my-component:defined');Creating meaningful measures
Now that you have marks in place, you can create measures that tell you how long critical paths are taking. The measure between connection and first render is especially valuable because it represents the user-facing initialization time:
JavaScript render() { // Your rendering logic here this.shadowRoot.innerHTML = ` <button>Click me</button> `; // Mark and measure first render if (!this._hasRendered) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); }}Using consistent naming conventions
This is crucial: establish a naming convention and stick with it. I recommend using the component's tag name followed by a colon and the event name:
component-name:definedcomponent-name:connectedcomponent-name:first-rendercomponent-name:upgrade
This pattern makes it easy to filter and analyze your metrics later. When you're looking at DevTools with dozens of components on the page, clear naming saves you from the cognitive overhead of figuring out which measurement belongs to what.
# Building reusable patterns
Rather than duplicating this code across every component, there are a few optimization options you can use: create or add it to a base class or use a mixin. Both approaches give you performance tracking for free, once set up, but they serve different architectural needs.
Option 1: Base class
A base class works well when you want a standardized component foundation. You set it up once, and every component that extends it gets performance tracking ✨automagically✨.
JavaScript export class PerformanceTrackedElement extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._hasRendered = false; } connectedCallback() { // Only mark the first connection if (!this.hasAttribute('data-perf-marked')) { performance.mark(`${this.localName}:connected`); this.setAttribute('data-perf-marked', ''); } // Trigger initial render this.render(); } _markFirstRender() { if (!this._hasRendered) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); // Measure the upgrade time performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); } }}Now any component can extend this base class:
JavaScript export class MyButton extends PerformanceTrackedElement { render() { this.shadowRoot.innerHTML = ` <style> button { padding: 0.5rem 1rem; border: none; border-radius: 4px; background: #007bff; color: white; cursor: pointer; } </style> <button>Click me</button> `; // Mark that first render is complete this._markFirstRender(); }}Option 2: Mixin
If you already have a base class hierarchy or want more flexibility, a mixin lets you add performance tracking to any class without changing your inheritance chain. This is particularly useful when working with existing component libraries or when you need to compose multiple behaviors.
JavaScript export const PerformanceTrackingMixin = (SuperClass) => { return class extends SuperClass { constructor() { super(); this._hasRendered = false; } connectedCallback() { // Only mark the first connection if (!this.hasAttribute('data-perf-marked')) { performance.mark(`${this.localName}:connected`); this.setAttribute('data-perf-marked', ''); } // Call parent connectedCallback if it exists if (super.connectedCallback) { super.connectedCallback(); } } _markFirstRender() { if (!this._hasRendered) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); // Measure the upgrade time performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); } } disconnectedCallback() { // Clean up performance marks performance.clearMarks(`${this.localName}:connected`); performance.clearMarks(`${this.localName}:first-render`); performance.clearMeasures(`${this.localName}:upgrade`); // Call parent disconnectedCallback if it exists if (super.disconnectedCallback) { super.disconnectedCallback(); } } };};Apply the mixin to any component class:
JavaScript // Apply to HTMLElementexport class MyButton extends PerformanceTrackingMixin(HTMLElement) { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { super.connectedCallback(); this.render(); } render() { this.shadowRoot.innerHTML = ` <style> button { padding: 0.5rem 1rem; border: none; border-radius: 4px; background: #007bff; color: white; cursor: pointer; } </style> <button>Click me</button> `; this._markFirstRender(); }}Or apply the mixin to your existing base class:
JavaScript // Or apply to your existing base classexport class MyCard extends PerformanceTrackingMixin(MyBaseElement) { // Your implementation}Composing multiple mixins
The beauty of mixins is that you can compose multiple behaviors. If you have other mixins for logging, analytics, or error handling, you can stack them:
JavaScript export class MyComponent extends PerformanceTrackingMixin( LoggingMixin( AnalyticsMixin(HTMLElement) ) ) { // Your component with all three behaviors}Choosing between base class and mixin
Use a base class when:
- You're starting fresh with a new component library
- You want a single, consistent foundation for all components
- You control the entire inheritance chain
- You prefer a simpler mental model
Use a mixin when:
- You need to add performance tracking to existing components
- You're working with multiple base classes
- You want to compose multiple behaviors
- You need flexibility to opt-in per component
# Accessing your performance data
Once you've instrumented your components, you need to actually look at the data. There are several ways to do this, each useful for different purposes.
Using the Performance Timeline
You can retrieve all your marks and measures programmatically:
JavaScript // Get all marksconst marks = performance.getEntriesByType('mark');// Get all measuresconst measures = performance.getEntriesByType('measure');// Get entries for a specific componentconst buttonMeasures = performance.getEntriesByName('my-button:upgrade');Viewing in DevTools
Performance marks appear in the Performance tab, where you can see them visualized on a timeline alongside other browser events. This is incredibly useful for understanding how your component initialization fits into the overall page load sequence.
To view your marks:
- Open Chrome DevTools
- Go to the Performance tab
- Record a new profile
- Look for your marks in the User Timing section
Integration with Lighthouse
Lighthouse extracts User Timing data and displays it in your reports. This means your custom performance marks show up automatically in your Lighthouse audits without any additional configuration.
# Reporting to analytics
Performance marks are only useful if you act on them. For production monitoring, you'll want to send these metrics to your analytics platform so you can track performance over time and across different user segments.
JavaScript _markFirstRender() { if (!this._hasRendered) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); const measure = performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); // Report to your analytics platform if (window.analytics) { window.analytics.track('Component Performance', { component: this.localName, duration: measure.duration, userAgent: navigator.userAgent }); } }}# Advanced patterns
Conditional instrumentation
You probably don't want performance overhead in production unless you're actively monitoring. Use environment flags to control when instrumentation runs. This works equally well with both the base class and mixin approaches:
With a base class:
JavaScript export class PerformanceTrackedElement extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._hasRendered = false; } connectedCallback() { if (this.shouldTrackPerformance() && !this.hasAttribute('data-perf-marked')) { performance.mark(`${this.localName}:connected`); this.setAttribute('data-perf-marked', ''); } this.render(); } shouldTrackPerformance() { // Only track in development or for a sample of production users const isDev = !window.location.hostname.includes('production.com'); return isDev || Math.random() < 0.1; } _markFirstRender() { if (!this._hasRendered && this.shouldTrackPerformance()) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); } }}With a mixin:
JavaScript export const PerformanceTrackingMixin = (SuperClass) => { return class extends SuperClass { constructor() { super(); this._hasRendered = false; } connectedCallback() { if (this.shouldTrackPerformance() && !this.hasAttribute('data-perf-marked')) { performance.mark(`${this.localName}:connected`); this.setAttribute('data-perf-marked', ''); } if (super.connectedCallback) { super.connectedCallback(); } } shouldTrackPerformance() { const isDev = !window.location.hostname.includes('production.com'); return isDev || Math.random() < 0.1; } _markFirstRender() { if (!this._hasRendered && this.shouldTrackPerformance()) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); } } disconnectedCallback() { performance.clearMarks(`${this.localName}:connected`); performance.clearMarks(`${this.localName}:first-render`); performance.clearMeasures(`${this.localName}:upgrade`); if (super.disconnectedCallback) { super.disconnectedCallback(); } } };};Measuring nested component trees
When you have components that render other components, you can track the entire composition tree:
JavaScript performance.mark('page-header:composition-start');// Render happens here with nested componentsperformance.mark('page-header:composition-end');performance.measure( 'page-header:full-composition', 'page-header:composition-start', 'page-header:composition-end');Tracking lazy-loaded components
For components loaded dynamically, track the time from import to render:
JavaScript performance.mark('my-component:import-start');const { MyComponent } = await import('./my-component.js');performance.mark('my-component:import-end');performance.measure( 'my-component:lazy-load', 'my-component:import-start', 'my-component:import-end');customElements.define('my-component', MyComponent);# Cleaning up marks and measures
Performance entries accumulate in the browser's performance buffer. Use clearMarks and clearMeasures to clean up once data is no longer needed.
JavaScript // Clear specific marksperformance.clearMarks('my-component:connected');performance.clearMarks('my-component:first-render');// Clear specific measuresperformance.clearMeasures('my-component:upgrade');// Or clear all marks and measuresperformance.clearMarks();performance.clearMeasures();For components that might be added and removed from the DOM multiple times, clear marks in the
disconnectedCallback:JavaScript disconnectedCallback() { performance.clearMarks(`${this.localName}:connected`); performance.clearMarks(`${this.localName}:first-render`); performance.clearMeasures(`${this.localName}:upgrade`);}# What to measure (and what to skip)
Not everything needs a performance mark. Focus on the moments that impact user experience:
Measure these
- Initial connection to DOM: Users are waiting for the component to appear
- First render complete: The component is now visible and interactive
- Component upgrade time: Total time from definition to first paint
- Critical async operations: Data fetching, heavy computations
- User interactions: Click handlers, form submissions, animations
Skip these
- Every update cycle: Too noisy, adds overhead
- Trivial getters and setters: Not worth the measurement cost
- Internal helper methods: Focus on user-facing milestones
- Style recalculations: The browser already tracks these
# Real-world example
Let's put this all together with a complete example of a button component that tracks its performance:
JavaScript export class PerformanceTrackedButton extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._hasRendered = false; } static get observedAttributes() { return ['label', 'disabled']; } connectedCallback() { if (!this.hasAttribute('data-perf-marked')) { performance.mark(`${this.localName}:connected`); this.setAttribute('data-perf-marked', ''); } this.render(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { this.render(); } } render() { const label = this.getAttribute('label') || 'Click me'; const disabled = this.hasAttribute('disabled'); this.shadowRoot.innerHTML = ` <style> :host { display: inline-block; } button { padding: 0.5rem 1rem; border: none; border-radius: 4px; background: var(--button-bg, #007bff); color: white; cursor: pointer; font-family: inherit; font-size: inherit; } button:disabled { opacity: 0.5; cursor: not-allowed; } button:not(:disabled):hover { background: var(--button-hover-bg, #0056b3); } </style> <button ${disabled ? 'disabled' : ''}> ${label} </button> `; // Mark first render complete if (!this._hasRendered) { this._hasRendered = true; performance.mark(`${this.localName}:first-render`); const measure = performance.measure( `${this.localName}:upgrade`, `${this.localName}:connected`, `${this.localName}:first-render` ); // Log in development if (window.location.hostname === 'localhost') { console.log(`${this.localName} upgraded in ${measure.duration.toFixed(2)}ms`); } } }}customElements.define('perf-button', PerformanceTrackedButton);performance.mark('perf-button:defined');# Common pitfalls
Forgetting to check for existing marks
If a component disconnects and reconnects, you'll create duplicate marks unless you guard against it. Always check if the mark already exists or use a flag to track marking state.
Over-instrumenting
Resist the urge to mark everything. Too many marks create noise and make it harder to find the signals that matter. Focus on initialization and critical user interactions.
Ignoring browser support
The User Timing API is supported in all major browsers, but always check if the
performanceobject exists before calling its methods:JavaScript if (window.performance && performance.mark) { performance.mark('my-component:connected');}Not testing on real devices
Your laptop is fast. Your users' phones might not be. Always test your instrumented components on representative devices to see what the real performance looks like.
# Dos and don'ts
Do
- Track component lifecycle milestones: connection, first render, upgrade time
- Use consistent naming conventions across your component library
- Report metrics to analytics for real-world performance tracking
- Clean up marks and measures when they're no longer needed
- Choose the right pattern: use a base class for new libraries, a mixin for existing ones
- Compose mixins when you need multiple behaviors
- Always call super methods in mixins to maintain the inheritance chain
Don't
- Mark every render cycle: too expensive and noisy
- Forget to check for performance API availability: defensive coding matters
- Ignore the data: measure, analyze, act
- Over-complicate your naming: keep it simple and descriptive
- Skip testing on slower devices: that's where performance matters most
- Forget to call super methods in mixins: breaks the inheritance chain
# Wrapping up
Performance monitoring doesn't have to be complicated. The User Timing API gives you exactly what you need to understand how your web components perform in the wild. By instrumenting key lifecycle moments and reporting metrics that matter, you'll have the insights you need to make data-driven decisions about optimization.
The pattern is straightforward: mark important moments, measure the time between them, and act on what you learn. Whether you choose a base class or a mixin depends on your architecture—base classes work great for new projects with a clean slate, while mixins shine when you need to add tracking to existing components or compose multiple behaviors. Either way, you set it up once and suddenly your entire component library has performance visibility without any per-component boilerplate.
Start simple; track connection and first render. See what the numbers tell you. Then expand to track the interactions and operations that matter most to your users. The data will surprise you, and that's exactly the point. You can't optimize what you can't measure, and now you can measure everything that matters.
Six months from now when you're investigating a performance regression or optimizing for a new market with slower network speeds, you'll be grateful you have this data. Your users will be grateful too, even if they never know you're tracking it. Build fast components, measure to prove they're fast, and keep measuring to make sure they stay that way.
Friday, October 31, 2025
A Storybook format that scales
Here's the thing about Storybook: it's incredibly powerful, flexible, and dynamic...but because of that flexibility, it can quickly devolve into a tangled mess of inconsistent patterns, duplicated code, and confusing file structures. We've all been there—you start with good intentions, build a few components, and six months later you're drowning in technical debt wondering why nobody can find anything.
This guide is your roadmap to building a Storybook that actually scales. Whether you're starting fresh or untangling an existing setup, you'll learn the patterns and conventions that separate the maintainable Storybooks from the ones that make developers groan. We're talking about practical, battle-tested approaches that'll make your component library a joy to work with instead of a source of frustration.
If you're new to Storybook, here's the elevator pitch: it's a dedicated workshop for building UI components and pages in isolation, separate from your business logic, APIs, and application state. That means you can develop and test those hard-to-reach edge cases without having to orchestrate your entire app into the right condition. Even better, it brings together your UI, examples, and documentation in one place, creating a single source of truth that makes it easier to discover and reuse existing patterns. It's your front-end playground and living style guide rolled into one.
# Philosophy
Before thinking about how you want to structure your Storybook files, you need to determine who your audience is and what they need to know. Storybook has a lot of powerful plugins and tools that can either enhance or confuse, so my advice is to be judicious about what you use and why. Is your audience primarily developers, designers, or product managers? Do they need to know about the component's props, events, and methods? How about accessibility requirements or performance? How many code examples and usage patterns do you need to provide?
Remember that less is more. You don't necessarily need to create a new story for every variant, state, or use case. Sometimes, a single documentation page and one default story with robust props available is enough. Letting your users explore the component through the Storybook interface will often reveal additional use cases and patterns that you may not have considered.
# Foundations
Before we get into the UI patterns and conventions, let's talk about some tooling choices that will help you create a consistent and maintainable Storybook.
Aliasing
Here's a quick win: set up your Storybook with package aliases instead relying on relative imports to reference other components. Your future self will thank you when it's time to refactor, and your paths will be more readable:
JavaScript // Goodimport { Template } from "@my-design-system/button/stories/template.js";// Avoidimport { Template } from "../../../button/stories/template.js";Rendering libraries
This suggestion is mostly for projects shipping vanilla HTML, CSS, and JavaScript without a framework. If you're building for React, Vue, or Angular, what rendering engine you use will be dictated by the framework.
For vanilla projects, I recommend leveraging lit to handle your templates. Lit is a library that provides a set of utilities for building web components that are easy to test, debug, and reuse. The lit-html library is lightweight and performant with utilities for dynamic rendering that you'll be grateful for later.
Bonus tip
You can even ship the template file with your component for CMS systems like Drupal or WordPress.The lit package provides features such as:
- Conditional classes: apply classes based on the Storybook
argsandcontext - Style maps: dynamically apply inline styles
- Conditional rendering: show or hide elements
- Optional attributes: only render attributes when they're defined
Compose, don't duplicate
When you're building complex components or patterns that leverage other components, resist the urge to copy and paste markup. Instead, import and call their templates. This keeps everything consistent and cuts down on the maintenance burden.
# File structure
The best architecture is self-documenting. When your files are organized in a way that is easy to understand and navigate, it becomes easier to contribute to the project and easier to find things when you need to. Though some of this will be dictated by the structure of your component library and the tools you have chosen, there are general principles that will help you create a standardized format. First, it's important to keep component-focused Storybook assets as close to the component source files as possible; this ensures the documentation stays up-to-date with component changes. Inside my component folder, I typically keep a
storiesfolder with the following files:component.stories.js- story definitions, controls (argTypes), default values (args), and configurationtemplate.js- reusable render functions that accept arguments and return rendered HTML- [optional]
component.docs.mdx- documentation for the component, including usage patterns, code examples, and best practices; alternatively, you can leverage JSDoc comments in your story file - [optional]
component.test.js- visual regression testing grids (used to define the component's states and variants for tools like 💕 Chromatic)
# Story files
The story file (
.stories.js) is where all the magic happens—it's where you define your component's stories, controls, and configuration. I like to keep all my stories for a component in a single file. This makes it easier to find and update stories, and it makes it easier to see all the stories for a component at a glance. That said, if a component (or more likely, a pattern) has a lot of stories, it might make sense to split them into multiple files. Let's break down each essential part of a story file.Default export
Your default export is the entry point for all your stories. It contains all the metadata and configuration for your component:
JavaScript export default { title: "Components/Button", component: "Button", argTypes: { /* control definitions */ }, args: { /* default values */ }, parameters: { /* additional configuration */ }, tags: [ /* organizational tags */ ]};Some of the guidance for these properties will differ based on what framework or rendering library you're using. I'm going to cover how to configure these properties for a vanilla project using lit, however, you can find more detailed information in the Storybook documentation for your specific framework.
Title
Pick a clear, human-readable title that reflects your component hierarchy. If your folder structure maps to how you want to group components in the sidebar, you can use a configuration object in your configuration file (
.storybook/main.js):JavaScript export default { stories: [ { // 👇 Sets the directory containing your stories directory: '../packages/components', // 👇 Storybook will load all files that match this glob files: '*.stories.*', // 👇 Used when generating automatic titles for your stories titlePrefix: 'MyComponents', }, ],};If you want to create a set of custom categorizations for your components, you can add as many paths as you need to the
title. Every slash-separated segment will be used as a group in the sidebar, under which the component will appear.JavaScript export default { stories: [ { title: 'Buttons/Close', }, ],};When determining how to group or nest your components, consider what distinctions would be most useful to your users. Does it benefit them to be able to dig into certain categories in one place instead of having to navigate through the entire library to find what they need? A few possible categories to consider are:
- Buttons
- Forms
- Graphs
- Layouts
- Navigation
Component
Specify the root component name here. This helps Storybook's documentation features understand what you're showcasing. If you're using a framework, this will typically be the component name or the imported component object. See the Storybook documentation for more information.
Controls
This is where you define the controls that users can interact with in the Storybook UI. Here's a pro tip: organize them into logical categories so people can quickly find what they're looking for:
- State—interactive states like focused, open, or disabled
- Variant—design variations like visual appearance or size
- Content—text, images, or nested components
- Advanced—edge cases or specialized configurations
JavaScript argTypes: { size: { control: "select", options: ["small", "medium", "large"], description: "The size of the button", table: { category: "Variant" } }, isDisabled: { control: "boolean", description: "Whether the button is disabled", table: { category: "State" } }}Document and restrict these categories to ensure consistency across your component library. If you have a lot of controls, you can also consider creating a shared controls file to import and reuse across your components. You can store these in a
controlsfolder inside your Storybook folder (i.e.,.storybook/controls/states.js,.storybook/controls/variants.js,.storybook/controls/content.js,.storybook/controls/advanced.js). This is a great way to keep your controls organized and easy to find.Bonus tip
If you identify your Storybook folder as a workspace in your package manager, you can import the controls into your story files using the workspace alias. For example, if you have a `controls` folder in your Storybook folder, you can import the controls like this:JavaScript import { states, variants, content, advanced } from "local-storybook/controls";Be sure to export all your controls via an
index.jsfile in yourcontrolsfolder in order to leverage this syntax most effectively.Default values
Always provide sensible defaults for all your controls. This way, your primary story renders in a useful state right out of the gate:
JavaScript args: { size: "medium", isDisabled: false, label: "Click me"}Not all controls have to have a default state, though. When given the choice between adding a meaningless
normalordefaultvalue into the options that doesn't actually trigger a state, I opt for setting the default toundefined.JavaScript // Good["outline", "subtle"]// Bad["default", "outline", "subtle"]Parameters
Parameters let you configure behavior and attach metadata. Think of them as the extra settings that make your stories more useful:
JavaScript parameters: { design: { type: "figma", url: "https://www.figma.com/..." }, actions: { handles: ["click", "focus", "blur"] }, status: { type: "stable" // or "experimental", "deprecated" }}Creating individual stories
Each story represents a specific state or variant of your component. Use
.bind({})to create stories that inherit from your default configuration, then override specific args to demonstrate different use cases:JavaScript export const Default = Template.bind({});Default.args = { // Override specific args for this story};export const Disabled = Template.bind({});Disabled.args = { isDisabled: true};export const WithIcon = Template.bind({});WithIcon.args = { icon: "settings", label: "Settings"};Naming conventions
Good story names are concise and descriptive. Here are some patterns that work well:
- Default—your primary interactive example
- With Icon—demonstrating optional features
- Loading State—showing specific states
- Error Variant—highlighting edge cases
And here's what not to do: don't repeat the component name in every story title. It's redundant and clutters your navigation.
Organizing with tags
Tags are your friends when it comes to controlling where stories appear and how they behave:
- Use
!devto hide stories from the sidebar—super useful for documentation-only stories - Use
!autodocsto exclude stories from auto-generated docs pages - Combine both tags for visual regression testing grids that shouldn't clutter your UI
# Template files
Your template file (
template.js) is where the actual rendering happens. Think of it as the engine that powers your stories.Template structure
Export a primary
Template(args, context)function that returns your rendered component. If you're using a library like Lit, this would be anhtmlTemplateResult.Here's the key: import and render nested component templates instead of duplicating their markup. We talked about this earlier, but it bears repeating—composition over duplication.
Standard arguments
Your templates should accept some common arguments to stay flexible:
rootClass—the base CSS class for your componentidandtestId—for identification and testingcustomClasses—an array of additional classescustomStyles—an object of custom CSS properties
Using directives for dynamic behavior
Most rendering libraries give you directives or utilities to handle dynamic rendering. Here's what you'll typically need:
classMap—for conditional classes based on yourrootClassstyleMap—for inline style objectsifDefined—for optional attributes that should only render when they existwhen—for conditional regions of your template
For interactive examples, use
context.updateArgsto toggle arguments (likeisOpenfor a modal). And when you need to pass modifiable CSS custom properties, do it throughcustomStyles—it's cleaner than hardcoding values.Composition patterns
When you're composing complex components, use parent wrappers to position the pieces and pass arguments down to child templates. Here are some real-world examples of how this might look:
- An action bar might compose a popover, close button, and action group
- An action menu might compose a popover with an action button trigger and a menu for content
- A coach mark might compose a popover, coach indicator, and internal layout
# Visual testing
If you're using a visual regression tool like 💕 Chromatic, you'll want to set up testing grids. These live in a separate
component.test.jsfile.Test grids
Most visual regression setups use some kind of variants helper to generate grids of different states and variants. You'll typically provide your
Templatealong with arrays of test data and state data.Here's the strategy: group many states and variants into a single snapshot grid to optimize your test runs. Nobody wants to wait forever for visual tests to complete.
In your main
*.stories.jsfile, export the grid and mark it with!autodocsand usually!devso it doesn't clutter your documentation or sidebar.Control patterns
Keep your controls organized and consistent across your component library. If you have common state controls (like
isOpen,isSelected,isHovered, orisFocused), put them in a shared location and import them.Use the same categories we talked about earlier: State, Variant, Content, and Advanced. And here's a neat trick—use
ifconditions to tie related controls together. For example, only showimageIsFixedHeightwhenhasImageis true.Don't be afraid to reuse controls from related components either. If another component already has the perfect icon dropdown setup, import it instead of rebuilding it from scratch.
# Interactions and accessibility
Actions and events
When you're working with nested components, reuse their action handlers by spreading them into your own
parameters.actions.handles. For interactive triggers like popover toggles, wire uponclicktocontext.updateArgsor leverage the behavior from child templates.Accessibility
Always provide ARIA attributes through your arguments—things like
aria-haspopup,aria-controls,aria-expanded, andaria-pressed. Use theifDefineddirective so they only render when needed.Keep focus, hover, and active states controllable in your test grids. This makes it way easier to verify your component looks right in all states.
When you're demonstrating popovers or menus, make sure trigger and popup IDs are consistent and exposed as arguments. Your QA team (and screen reader users) will appreciate it.
# Documentation and metadata
Design links
Link your stories to design files (like Figma) through
parameters.design. This creates a direct connection between your implementation and the design source of truth.Package information
Include your
package.jsonand any metadata files in your parameters. This powers documentation blocks and helps users understand what version they're looking at.Component status
Use
parameters.status.typeto communicate where your component is in its lifecycle—is it stable, experimental, or deprecated? Add tags like"migrated"to track major updates or migrations.# Dos and don'ts
Let's wrap up with some quick guidelines to keep you on the right track:
Do
- Import child component templates instead of copying their markup
- Keep your
template.jsfocused on the component itself—acceptcustomClassesandcustomStylesfor composition - Categorize your controls and set sensible defaults in
args - Hide VRT-only stories from your sidebar and docs using tags
- Reuse shared controls from a common location instead of duplicating them
Don't
- Don't hardcode IDs—use a helper function to generate random IDs
- Don't duplicate markup from nested components—import and compose instead
- Don't skip accessibility—always include proper ARIA attributes
# Code examples
Here are some practical code snippets to get you started.
Story default export
Here's what a typical story file default export looks like:
JavaScript import packageJson from "../package.json";export default { title: "Components/Button", component: "Button", argTypes: { size: { control: "select", options: ["small", "medium", "large"], table: { category: "Variant" } }, isDisabled: { control: "boolean", table: { category: "State" } } }, args: { size: "medium", isDisabled: false, label: "Click me" }, parameters: { design: { type: "figma", url: "https://www.figma.com/..." }, packageJson, status: { type: "stable" } }, tags: ["autodocs"]};Template composition
When you're composing complex components, here's how you might structure it:
JavaScript import { Template as Popover } from "@my-design-system/popover/stories/template.js";import { Template as Button } from "@my-design-system/button/stories/template.js";export const Template = (args, context) => Popover({ ...args, isOpen: true, trigger: (passthroughs, ctx) => Button({ label: "Open Menu", ...passthroughs }, ctx), content: [/* child content templates */]}, context);Test grid setup
If you're setting up visual regression tests, your test file might look like this:
JavaScript import { Template } from "./template.js";export const TestGrid = { render: Template, parameters: { chromatic: { disableSnapshot: false } }};export const Default = TestGrid.bind({});Default.tags = ["!autodocs", "!dev"];Default.args = { // Test-specific args};# Wrapping up
Building a Storybook that scales isn't about following every best practice to the letter—it's about making intentional choices that serve your team and your users. Throughout this guide, we've covered the foundational decisions that'll keep your component library maintainable: organizing your files so they're easy to navigate, composing components instead of duplicating markup, structuring your story files with clear defaults and well-organized controls, and never forgetting about accessibility.
The most important takeaway? Consistency wins. Pick the patterns that make sense for your project—whether that's keeping all stories in one file or splitting them up, using Lit for vanilla projects or sticking with your framework's conventions, setting up visual regression tests or relying on manual QA. Whatever you choose, document it, share it with your team, and stick with it. A consistent Storybook with clear conventions will always be more valuable than a perfectly optimized one that nobody can navigate.
Six months from now, when you're diving back into a component you haven't touched in ages, you'll be grateful you put in the effort upfront. Your teammates will thank you. Your designers will thank you. And most importantly, you'll avoid that tangled mess of technical debt we talked about at the start. Let's build something great!
- Conditional classes: apply classes based on the Storybook
Sunday, October 19, 2025
AI as a reflection of our values
https://openai.com/index/why-language-models-hallucinate/
I first encountered this article and subsequent white paper a few days ago, and I've been ruminating on it all weekend. The white paper employs the metaphor of students taking a multiple-choice test to illustrate the grading process for the efficacy of an AI model. I find this metaphor particularly evocative because multiple-choice testing is frequently criticized for being an inadequate way to determine a student's understanding of the subject.
The paper highlights that since making a guess increases the likelihood of getting the right answer compared to leaving it blank, AI logically opts to return an assumption or estimate instead of stating, "I don't know." As a student of the United States public education system, I was well acquainted with this advice. After all, leaving an answer blank guarantees a zero, while a guess offers a one-in-four chance of earning a point.
Therefore, it's better to guess, isn't it?
# What's the harm of a guess?
The problem with a child guessing on the test is that the teacher loses an understanding of what the child really learned and where they need to reinforce or take a different approach for previous lessons. On top of this, some children are really good at taking standardized tests. Does that mean they know what it is we wanted them to learn from that lesson?
Map this back to the article on AI hallucinations, and we see that, yet again, AI is holding up a mirror to our flaws. We entered the work with assumptions and expectations, and the models returned answers that in fact reflect our own faulty definitions of reality and truth. It sought to bolster our egos because reinforcing pre-conceived notions is better received by the audience than telling someone their premise is invalid. What can we learn about training AI models from alternative educational models such as Constructivism, which aims to encourage problem-solving and critical thinking, connecting new data with previous knowledge?
How do we grade and assess the success of a model without falling back to assessment methods that we already know to be inconclusive for our children?
Friday, October 3, 2025
From Figma to code: why design systems need continuous collaboration
The most high-performing design systems teams I've been on are ones with designers and engineers working in parallel. This is not to say that it's easy to get into this workflow because so often design projects are iterating well beyond where engineering is able to implement. When we slow down though and talk through this work together, I think we find so much opportunity to ask each other meaningful questions and challenge our pre-held notions and expectations.
What does this look like in practice? Below I'll outline a few of the questions I generally have when engaging with component designers.
# Squish-ification
The web is a notorious squishy platform. Screen sizes, color palettes, user preferences and needs all vary widely by browser and user. Some features aren't implemented in all browsers and others are implemented but behave in subtly different ways. Some users prefer large text and others want dark themes. There are a thousand ways that we engage with the web and a lot of angles to come at building widgets for those surfaces. So when I'm approaching a new pattern or design, I start by asking myself a few questions:
- What are the min and max sizes or ratios where this thing looks good?
- Should spacing or padding scale up or down with type? Or should the spacing be static and not influenced by increases or decreases in base font sizes?
- What is the general ratio you'd like this widget to take up on the screen?
- How do I engage with this element if I don't use a mouse? This is not just an accessibility concern but it also impacts how elements are constructed in the DOM because visual order doesn't necessarily equal semantic order. Even if I reorder elements visually using layouts, my keyboard will continue to navigate the elements in the order they exist in the DOM (unless you update that order with tabindex as well).
- Are there any contextual customizations to an element? Should this thing look or behave differently if it exists inside something else or if it only has a certain amount of space available?
# Starting the conversation
What these questions really get at is the heart of the design. When engineers start poking at a design, what they're wanting to do is really get inside your thought process and build something that fully maps to what you, as a designer, envisioned. It's so hard to communicate intent in static design documentation and as dynamic and squishy as the web has become, these back-and-forths are more valuable and important than ever.
No posts match this tag.