More than half of my component library arrived this October. When I lined the new pieces up next to the old ones, something looked off and at first I couldn't say what. The colours were right. The corners were the right size. But next to the accordion, one of the first things I built, they looked drawn. The accordion looked lit.
That difference is most of what this post is about.
Björk UI is my component library. It has about 190 components, it's dark by default, and you install pieces of it with the shadcn CLI. It has the buttons and tables every library has, and it has the things I actually love building: an order book, a sankey, a voice beam, a click wheel, a pen that thins out as you write faster.
I want to explain how I design, and I'd rather show it than say it. I treat a page as a canvas, where the spacing, the lines, the colours and the type are all decisions, so this post takes them one at a time. The library is the evidence: the rules I hold it to, the numbers behind them, and the audit where I scored every component and found out which rules were real. Every figure below is live, and most of them are the real components.
Start dark
I'm a fan of dark colours. Not black, though. I want a dark base with colour on top, at a contrast you can read all day.
The library's surface is #121212, the same dark grey Material recommends over pure black for dark themes (Material Design). Text sits on it in six tiers. The top one is #ededed, an off-white, at full strength. The other five are that same colour at 90, 72, 52, 36 and 22 percent. One colour at six strengths keeps everything in one family, and the hierarchy is nothing but value.
The bottom of that scale is where I have to be honest. Muted text at 52% comes out at 5.0:1, which clears WCAG's 4.5:1 bar for body text (WCAG 2.2). Soft text at 36% is 3.0:1. That's enough for large type and for the edges of controls (WCAG 2.2), and it fails for anything you're meant to read at body size. When I audited the library, soft text was turning up in exactly those places: the body of a reasoning panel, chart axis labels, option labels.
Drag the slider and you'll find where it crosses. It's 49%. My own audit suggested 48%, which lands at 4.4:1, a hair short, so check the math before you trust a round number. The library is still at 36% as I write this.
On a dark interface, passing 4.5:1 is only the floor. The formula behind WCAG 2 overstates the contrast between dark colours (APCA), so I also check the dark tiers by eye on a real screen.
The accent is #ec5c13, a burnt orange. As text on the surface it's 5.4:1, which is fine. As a button with a light label on it, it's 3.1:1, which isn't. So filled buttons and badges use a deeper rust, #b84a12, where the same label reaches 4.7:1. The pair at the bottom of the figure is that change.
Status colours only show up when something has a status: green for done, amber for waiting, red for broken. They sit between 6:1 and 10:1 on the surface so they pop, and they're rare so they mean something. That's what I like about the way Notion, Vercel, OpenAI and 1X design. Clean neutrals almost everywhere, and colour only where it carries information.
Depth you feel
I want cards to pop off the page and still be easy on the eye. On a dark page that's harder than it sounds. A card's fill is #121212 and the stage behind it is #111111. Those two greys are 1.008:1 apart. Nobody can see that difference, and I don't want them to. The edge has to come from light.
The surface is four layers on one element. The border is #1c1c1c, only 1.1:1 against the fill, so you feel it more than you see it. Just inside it, a half-pixel highlight runs along the top edge. That's the second, lighter border people notice without knowing why. Below that, a faint glow falls from the top edge into the face, and one drop shadow sets the whole thing on the page.
None of the four does much alone. Step through them in the figure and each one adds less than you'd expect. Take any one away from the finished card and it gets worse in a way that's hard to name. That's what I meant by drawn and lit. A full-strength 1px border on a flat fill draws an edge. Light and shadow describe one. Vercel's interface guidelines land in the same place: layer shadows the way real light layers them, and pair borders with shadows so edges stay crisp (Vercel). Hover changes a surface's fill and border. It never changes the shadow, because the light in the room didn't move.
Light mode uses a warm paper colour, #fffcf6, and its shadows are a warm brown. Grey shadows on warm paper look dirty. Josh Comeau makes the same case: a black shadow desaturates whatever it falls on, so match the shadow to the background's hue (Josh W. Comeau). Light mode also gets two 1px lines inside the side edges, warm on the left and white on the right, so the tray has walls and catches light from one side.
Spacing from shadcn, corners from geometry
I didn't invent a spacing system. shadcn/ui got the defaults right: Tailwind's 4 px steps, quiet surfaces, nothing fighting for your attention. I took that as my floor. The library ships the way shadcn does, too. You add a component with the CLI, the source lands in your project, and from then on you own it. The shadcn docs put the idea in one line: "It is how you build your component library." (shadcn/ui)
Where I needed my own rules was corners. The library has a radius scale: 24 for the preview stage, 22 for the options panel, 20 for cards, 18 for rails and tables, 16 for menus, 13 for controls, 10 for chips, and 8 or 6 for badges. Then one rule for nesting. An inner corner is the outer corner minus the padding between them. Vercel's guidelines ask for the same thing and call it concentric. It's easier to see than to explain.
Give the segments the same radius as the rail and the gap between the two curves swells at every corner. The geometry is short. With outer radius R, inner radius r and padding p, the gap along the diagonal is this:
gap = √2 · p + (r − R)(√2 − 1)
When r = R − p that collapses to p, the same as the sides. When r = R it's √2 · p, which is 41% wider at any padding. That bulge is what your eye catches before you can say what's wrong.
The rule has a limit. Past 8 px of padding the inner piece stops reading as part of the outer one. At that point it's content, so it takes a radius from the scale instead, never more than the outer radius minus 8.
I'll admit the scale is more of a goal than a fact. It has nine steps. When I counted, the code had 22 different corner radii in it.
A typeface cut from Geist
The type on this site and in the library is Bjork Grotesk. You're reading it now. I didn't draw it from scratch. It's generated from Geist, Vercel's open source family (Geist), by a script that rebuilds every style in seven steps.
Geist is a great interface face. I wanted it a little more compact, with a slightly taller x-height and a few details that would make it feel like mine. So the script condenses the outlines to about 92% for the text cut and 88.5% for the display cut, and raises the lowercase by 3.5% and 2%. Then it gives a few letters some character. R, K and k get a kicked leg. J and j get a longer hook, Q a longer tail and t a longer arm. They're shear edits, so the strokes stay straight and keep their weight, and part of each reach is handed back as spacing so a letter doesn't crowd its neighbour.
The most interesting bug was contrast. Squeeze one instance of a font horizontally and the vertical stems get thinner while the horizontal strokes don't, so the contrast flips and verticals end up lighter than horizontals. It looks wrong even when you can't say why. The fix was to build each glyph from two instances of Geist. The x coordinates come from a heavier weight, so the stems survive the squeeze. The y coordinates come from the target weight, so the horizontals keep Geist's own contrast.
The other bugs were less interesting and more embarrassing. The italic was slanted by a number in degrees, handed to a function that wanted radians. The figures were supposed to be tabular and weren't. Now the default figures are tabular, all ten on one width, because numbers in an interface change while you're looking at them, and a total that shuffles sideways every time it ticks is noise. Vercel's guidelines ask for tabular numbers too. The proportional figures are still in the font for running text, and each style ships at about 13 KB.
Type is also where the audit was hardest on me. After the fixes it was the weakest axis, 7.19 out of 10. 68 of 188 components set text at 9 or 10 px. Some of that is small uppercase labels in the mono face, which my rules allow at 10. A lot of it isn't. My own rule says body text never goes under 11, and a better typeface won't fix the places where I broke it.
Motion that feels good
I like things to feel good, and most of that feeling is motion you barely notice. Most of the rules I follow come from Emil Kowalski's writing on animation. Use ease-out for things that enter or leave. Keep interface motion under about 300 ms. Never scale something in from zero (Emil Kowalski). And the one people skip: how often someone sees an animation decides whether it should exist at all. Something you open hundreds of times a day shouldn't make you wait for it (Emil Kowalski). Rauno Freiberg pulled the motion out of a tool's core interactions for the same reason, once they started to feel sluggish (Rauno Freiberg).
In the library that turns into a few constants. A press takes about 140 ms and scales to 0.97. A whole card only goes to 0.995, because on something that big 3% is 18 px of movement. Tooltips take 160 ms, menus 200 and panels 280. Drawers and sheets use cubic-bezier(0.32, 0.72, 0, 1), the curve Emil uses in Vaul, which he took from Ionic because it's close to the iOS sheet (Emil Kowalski). Entrances start at 0.9 scale or above, with a fade. Nothing uses transition: all, which Vercel's guidelines rule out too, because all catches layout properties you never meant to animate (Vercel). By the second audit there were none left.
I also keep motion rare on any one screen. One thing moving tells you where to look. Five things moving just tell you the page is busy.
For anything that follows a hand or can change its mind halfway, I use springs. A tween is a promise about time: this will take 280 ms and end here. When the target moves at 140 ms, the tween has to break that promise, and the usual way is to start a fresh curve from wherever it is, at a standstill. A spring keeps its velocity when you give it a new target, so it turns instead of stopping. Apple's talk on springs makes the same point: they're the only kind of animation that stays continuous both from rest and from a moving start (Apple, WWDC23).
Let it play, or press Move twice quickly. The linear and ease-out lanes turn on a corner at the second press. The spring rounds it off.
The library has six springs, named by job: press, snappy, standard, soft, settle and blur-in. Snappy is stiffness 400 and damping 25 at mass 1, a damping ratio of 0.63. That's about 8% overshoot, settled in about half a second. Standard is 300 and 30 at mass 0.8, a ratio of 0.97 and next to no overshoot. Nobody should have to remember any of that, which is the whole point of the names.
Even named, stiffness and damping are hard to think in. Nobody pictures damping 25. People picture how far a thing overshoots and how soon it peaks, so the library's spring tuner puts the handle right on the peak of the curve and solves for the physics.
The solve is two lines. The overshoot OS gives the damping ratio ζ, and the time to the peak gives the natural frequency. Stiffness and damping fall straight out of those two and the mass.
ζ = −ln(OS) / √(π² + ln²(OS))
ωn = π / (tp · √(1 − ζ²))
stiffness = m · ωn², damping = 2 · ζ · ωn · m
One bug taught me to trust closed forms. The spring helper used to step its springs with semi-implicit Euler, which is fine for a game, and it drifted up to about 4% of the travel away from the true curve. That's enough for a preview and the live animation to disagree about where a spring lands. It now advances with the exact solution, so a spring stepped frame by frame lands where the formula says it will, at any frame rate.
When the motion is the point
Keeping motion under 300 ms is a good default. I broke it once, on purpose.
The flap ledger is a split-flap departure board. Its first version flipped each flap in 70 ms, and every letter of a word flipped at the same moment. It was fast and correct, and it felt like a blink. On a board like that, the flip is the news. So I slowed it down.
Each flap now takes 130 ms. The top half falls under gravity for the first 58% of the flap and the bottom half slaps down in the rest. Letters start 12 ms apart, so a word ripples as it changes, and the landing bounces 10° instead of 6°. The moving half darkens as it turns and catches a lit edge, so it reads as a card instead of a texture. One update now rolls across the board in about a second and a half to two. That's far too slow for a button and right for a board. Frequency is still the test. A board changes far less often than you click.
Charts are my favourite thing to build
Charts are where design is hardest to see and easiest to feel: a number that glides instead of jumping, a level that flashes once when it changes and then gets out of the way.
Charts in the library are bare. No card, no tray. The marks are the material. Tufte's data-ink ratio is the reason: of all the ink in a graphic, as much as possible should be showing data (Edward Tufte). The surface is for things you hold. A chart is something you read.
Live charts get new data on a timer, and a tween is the wrong tool for that. A 300 ms ease-out that restarts on every update spends its whole life in its fastest part, and the line turns into a row of corners. So the charts don't tween. Every animated value closes a fixed share of the gap to its target each frame:
// Moves cur toward target with time constant tau, at any frame rate.
function damp(cur: number, target: number, tau: number, dt: number) {
return target + (cur - target) * Math.exp(-dt / tau)
}
There's no clock to restart. A new value halfway through a glide just becomes the new thing to chase. 23 of the 30 charts use that one function, with τ between 80 and 160 ms.
Emil makes the opposite case too: a functional chart, in a banking app say, can be better with no animation at all (Emil Kowalski). For a static chart I agree. Mine move because the data moves, and the motion is there to keep your eye on a value while it changes. It's done in about a third of a second.
Here are five of them, for real.
Every one of them works from the keyboard too. On the latency chart the arrow keys move the threshold and the bracket keys change the bin count. On the drawdown chart the number keys jump straight to each drawdown it found.
The order book is the one I'm proudest of. A feed sends a snapshot and then deltas, the way an exchange socket does, and the book folds however many messages arrive into one paint per frame. A level that changes flashes for 450 ms, and never more than once every 90 ms, so a busy market shimmers instead of strobing. Clicking a level drops its price into the order ticket. Getting you from looking to acting is the whole job of a book.
Grouping is where a book can quietly lie. Traders group price levels into coarser buckets to read depth, five-cent levels into quarters, say. The obvious way to do it rounds each price to its nearest bucket, and that breaks the book.
Round to nearest and the best bid and the best ask can land in the same bucket, so the grouped book claims someone will pay as much as someone else is asking. The library rounds bids down and asks up, so a bucket can never reach across the spread. It also quotes the spread from the raw prices, so grouping never makes the market look wider than it is.
The sankey has its own quiet decisions. Node positions come from 24 relaxation passes, each pulling a node toward the value-weighted middle of its neighbours. Then each node has to stack its ribbons. The easy order is the order the data arrived in, and it tangles.
Sorting each node's ribbons by where their other end sits removes every crossing between ribbons that share a node. The crossings that are left are the ones the layout forces, and you can't do better without moving nodes.
The audit's fairest complaint about my charts was colour. The categorical palette is six hues, orange, blue, green, violet, pink and amber, and the accent is just one of the six. On the donut the biggest slice came out violet and my brand colour was a sliver. It reads like any dashboard kit. The fix it suggested is the one I'd give anyone else: anchor the set on the accent, add neutral steps, and keep one cool colour as a counterpoint. The plot in the next figure already uses that recipe.
Pieces I'd show anyone
The handoff beam is my favourite single component. It's a line of light along the bottom edge of a voice input that shows whose turn it is. Idle is a short line that breathes once every five seconds. Listening spreads wide and moves with your voice, with a faint drift even in silence so it never looks switched off. Thinking narrows to a shimmer that sweeps from the left every second and a half. Speaking settles into a wider, calmer swell.
The trick is to treat each phase as a weight between 0 and 1. All four weights ride the same critically damped spring, toward 1 for the current phase and 0 for the rest. Four identical springs chasing targets that add up to 1 keep adding up to 1 the whole way, so every frame is a valid mix of shapes. The beam never jumps from one shape to the next. It blends.
The voice level gets smoothed twice: once by an envelope with a 70 ms attack and a 260 ms release, then again by a pass whose time constant grows from 70 to 270 ms as the beam moves into speaking. That one line is why speaking looks calm while listening looks alert.
Three of my other favourites measure something about your hand.
The click wheel is an iPod wheel. Drag around the ring to scroll a list. It clicks twelve times a turn, with a 3 ms vibration on each click where the device has one. Let go mid-drag and it coasts. The release speed is measured over the last 80 ms and capped at six items a second, so no flick takes more than about 1.2 seconds to settle. The coast decays as 0.94 to the power of dt × 60, so it feels the same at 60 Hz and 120 Hz, and below half an item a second it snaps to the nearest track. The wheel also measures its own geometry once per press, never during the drag, so nothing on the gesture path makes the browser recalculate layout.
Velocity ink is a signature pad where speed stands in for pressure. The pen gets thinner as you move faster, from 4.2 px down to 1.2. The brush does the opposite and swells with speed, tapering at both ends. Speed is smoothed so one fast sample can't spike the line, and the stroke is resampled every 2 px along a Catmull-Rom curve.
The adaptive precision slider is the one I'd steal first. Drag it and it's a slider. Let your pointer drift away from the track while you drag and it slows down, so coarse and fine control live in one gesture with no modifier key:
// Full speed within 12px of the track, then every 48px halves what's left, down to 5%.
const gain = 0.05 + 0.95 * 2 ** (-Math.max(0, distance - 12) / 48)
On touch screens the gain is off unless you turn it on, and the falloff is gentler when you do. A thumb wanders up and down while it drags, and the slider shouldn't read that as a request for precision.
And two pieces of type I keep coming back to. Liquid text treats a word as a thick fluid. Drag through a letter and a bead of ink pulls off it, then sinks back with a little wobble when you let go. Chromatic text stays one crisp colour until you move across it fast, then smears into a spectrum along the direction you moved and settles. Both run in WebGL, and both pause the moment they leave the screen, because an effect that burns battery on a page nobody is looking at isn't a nice effect.
Fewest taps
All of that is the part people screenshot. The part I care about more is whether you got your thing done, and how many taps it took.
Take the model selector, the menu in an AI composer where you pick which model answers. When you open it, focus moves into its search field, so you can type straight away. Type "mini", press Enter, and you're done: one tap and five keys. A picker that leaves focus on its button makes you tap the search box first, every single time.
There's a reason it's built that way and not with autofocus on load. Vercel's guidelines say to autofocus a lone primary input on desktop and rarely on phones, where the keyboard jumping up shifts the whole layout (Vercel). And on an iPhone, a focus that a script makes on its own won't raise the keyboard at all. An Apple WebKit engineer explained that it only happens when the focus comes from the user's own gesture (WebKit bug 195884). Both point at the same rule: focus follows the person. The selector moves focus only when you opened it, never when the page loads. The keyboard should be there when you asked for it and never when you didn't.
The search helps more than it looks. Every word you type has to match somewhere in a model's name, provider, description or capabilities, so you can search for what you need instead of what it's called. "cheap" finds Kestrel Mini. "vision tools" finds every model that can read images and call tools.
The other half of fewest taps is doing the work before anyone asks. The drawdown chart finds the three deepest drawdowns before you look and selects the worst one, so the question you came with is already answered when the chart appears. The order book's click goes straight to the ticket. Every step I can do ahead of time is a step you don't take.
Speed counts too. Jakob Nielsen's old limits still hold: about a tenth of a second feels instant, about a second keeps your train of thought, and ten seconds loses you (Nielsen Norman Group). Google's Web Vitals put numbers on a page: the largest paint inside 2.5 seconds, a response to input inside 200 ms, and almost no layout shift (web.dev). On this page every live component you've played with loaded only when you scrolled near it, into a slot that already had its height, so nothing below it jumped. Canvases cap their pixel ratio at 2, most drop to 1.5 on touch screens, and every loop stops when it's off screen.
Phones get their own rules. Apple says a button generally needs a hit region of at least 44 by 44 points (Apple), so the buttons I made for this post's figures grow to at least that on a touch screen. The chart surfaces use touch-action: pan-y, so a vertical swipe still scrolls the page and a sideways one reads the chart. And a tap on a chart keeps its tooltip until your next tap, because on a touch screen the pointer leaves the moment your finger lifts.
Scoring all of it
This is the least flattering part. The library had grown from its first release in May to 187 components, and I could see it drifting. So I audited all of it. I read the source of every component and every demo page, swept the code for the rules a script can check, and scored each one from 1 to 10 on seven axes: surface, options placement, motion, SVG craft, type, light mode and overall taste.
Taste came out high, a mean of 7.6, with 125 of 187 components at 8 or 9. The weakness was consistency. The charts were a good example. They had the best motion of any family, 8.9, and 9.0 for drawing. Where their options lived scored 3.3.
The rules had been there from the first commit. The surface token and the floating options panel were both in the initial release. What drifted was their reach. The wrapper most demo pages used had no slot for the options panel, so a page that wanted options had nowhere to put them but the stage. The plan I wrote for the new families told every new demo to put its controls inline under the component, and 71 of them did exactly that. And the kits that have to look right in any theme (tables, cards, charts and the AI pieces) each kept their own hand-typed copy of the surface tokens, every copy a little thinner than the original. The dark table container lost its shadow entirely. The new cards lost the glow. None of that is wrong on its own. Next to the accordion, all of it looked drawn.
That's the lesson I'd put on a wall. A canon that lives only in a token file is a suggestion. It becomes a canon when the easiest path produces it.
So the fixes went into the easy paths. One shared surface module that every kit spreads, so the copies can't drift apart again. The table fix was one line and it reached 19 tables. One tooltip fixed 27 charts. One card frame fixed 11 cards. Every demo page got a slot for the options panel, and the options moved into it. Then I scored everything again.
Everything a rule could check jumped. Options placement went from 3.66 to 8.47 and surface from 6.51 to 8.32. The average across the rubric went from 7.21 to 7.85. Overall taste went from 7.59 to 7.55. It didn't move.
Conformance went up and taste didn't, and once I saw it the reason was obvious. The fixes touched exactly what the first audit measured and almost nothing it didn't. The palette, the gallery thumbnails, the type scale and the size of the options panel weren't on the rubric, so nothing touched them. One of them got worse because of a fix. On a 1440 by 900 laptop the preview pane is 644 px wide, and the options panel I'd moved everything into is 286 px. Sixteen pages opened it by default, so it took 44% of the stage, and on some of them, the click wheel and the model selector among them, it sat right on top of the component it was there to control.
So the second lesson: the rubric becomes the canon's blind spot. Whatever you score improves. Whatever you don't score drifts, and the numbers won't tell you.
The proof arrived the same day. The order book, which I built right after the fixes, shipped with its options inline under the chart. The old helper that made inline options easy was still in the codebase, and the easy path won again.
What I check now
When I look at an interface now, mine or anyone's, these are the questions I ask first.
- Does the softest text you're meant to read clear 4.5:1?
- Does the edge of a card come from light, or from a line?
- Do nested corners share a centre?
- Can every animation be interrupted, and does it keep its speed when it is?
- Does a live number glide to its new value, or restart?
- How many taps does it take to do the thing you came for?
- Is the work done before the user asks for it?
- What isn't on the rubric?
The accordion still looks lit. Most of the library does now. The parts that don't are the parts I haven't learned to measure yet.
Sources
- Material Design, Dark theme, Google.
- W3C, Understanding Success Criterion 1.4.3: Contrast (Minimum), WCAG 2.2.
- W3C, Understanding Success Criterion 1.4.11: Non-text Contrast, WCAG 2.2.
- Myndex, APCA in a Nutshell.
- Vercel, Web Interface Guidelines.
- Josh W. Comeau, Designing Beautiful Shadows in CSS.
- shadcn, Introduction and Registry, shadcn/ui docs.
- Vercel, Geist font repository.
- Emil Kowalski, 7 Practical Animation Tips.
- Emil Kowalski, You Don't Need Animations.
- Emil Kowalski, Building a Drawer Component.
- Emil Kowalski, Good vs Great Animations.
- Rauno Freiberg, Invisible Details of Interaction Design.
- Apple, Animate with springs, WWDC23.
- Edward Tufte, The Visual Display of Quantitative Information.
- WebKit Bugzilla, Bug 195884: Autofocus on text input does not show keyboard.
- Jakob Nielsen, Response Times: The 3 Important Limits, Nielsen Norman Group.
- Philip Walton, Web Vitals, web.dev.
- Apple, Human Interface Guidelines: Buttons.