[{"data":1,"prerenderedAt":1628},["ShallowReactive",2],{"i-lucide:chevron-down":3,"i-lucide:search":8,"i-lucide:sun":10,"i-lucide:moon":12,"i-lucide:arrow-right":14,"i-lucide:menu":16,"i-lucide:mail":18,"i-simple-icons:x":20,"i-simple-icons:linkedin":22,"i-simple-icons:dribbble":25,"i-simple-icons:github":27,"blog-\u002Fblog\u002Fen\u002Fdense-tables-that-stay-readable":29,"blog-related-\u002Fblog\u002Fen\u002Fdense-tables-that-stay-readable":276,"i-lucide:chevron-right":1614,"i-lucide:calendar":1616,"i-lucide:clock":1618,"i-lucide:link":1620,"i-lucide:component":1622,"i-lucide:sparkles":1624,"i-lucide:blocks":1626},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,24,false,"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m6 9l6 6l6-6\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"m21 21l-4.34-4.34\"\u002F>\u003Ccircle cx=\"11\" cy=\"11\" r=\"8\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"4\"\u002F>\u003Cpath d=\"M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 12h14m-7-7l7 7l-7 7\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 5h16M4 12h16M4 19h16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":19},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"m22 7l-8.991 5.727a2 2 0 0 1-2.009 0L2 7\"\u002F>\u003Crect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":21},"\u003Cpath fill=\"currentColor\" d=\"M14.234 10.162L22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299l-.929-1.329L3.076 1.56h3.182l5.965 8.532l.929 1.329l7.754 11.09h-3.182z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":23,"hidden":24},"\u003Cpath fill=\"currentColor\" d=\"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037c-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85c3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.06 2.06 0 0 1-2.063-2.065a2.064 2.064 0 1 1 2.063 2.065m1.782 13.019H3.555V9h3.564zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0z\"\u002F>",true,{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":26},"\u003Cpath fill=\"currentColor\" d=\"M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12s-5.385 12-12 12m10.12-10.358c-.35-.11-3.17-.953-6.384-.438c1.34 3.684 1.887 6.684 1.992 7.308a10.28 10.28 0 0 0 4.395-6.87zm-6.115 7.808c-.153-.9-.75-4.032-2.19-7.77l-.066.02c-5.79 2.015-7.86 6.025-8.04 6.4a10.16 10.16 0 0 0 6.29 2.166c1.42 0 2.77-.29 4-.814zm-11.62-2.58c.232-.4 3.045-5.055 8.332-6.765q.202-.067.405-.12q-.392-.879-.832-1.74C7.17 11.775 2.206 11.71 1.756 11.7l-.004.312c0 2.633.998 5.037 2.634 6.855zm-2.42-8.955c.46.008 4.683.026 9.477-1.248a66 66 0 0 0-3.8-5.928a10.28 10.28 0 0 0-5.676 7.17zM9.6 2.052c.282.38 2.145 2.914 3.822 6c3.645-1.365 5.19-3.44 5.373-3.702A10.2 10.2 0 0 0 12 1.764c-.825 0-1.63.1-2.4.285zm10.335 3.483c-.218.29-1.935 2.493-5.724 4.04c.24.49.47.985.68 1.486c.08.18.15.36.22.53c3.41-.43 6.8.26 7.14.33c-.02-2.42-.88-4.64-2.31-6.38z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":28},"\u003Cpath fill=\"currentColor\" d=\"M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"\u002F>",{"id":30,"title":31,"author":32,"body":35,"category":260,"cover":261,"coverAlt":31,"date":262,"description":263,"draft":6,"extension":264,"featured":6,"lang":265,"meta":266,"navigation":24,"path":267,"readingTime":268,"seo":269,"stem":270,"tags":271,"__hash__":275},"blog\u002Fblog\u002Fen\u002Fdense-tables-that-stay-readable.md","Dense tables that stay readable",{"name":33,"role":34},"Kwame Boateng","Principal Product Designer",{"type":36,"value":37,"toc":251},"minimark",[38,42,50,55,58,61,83,86,90,93,132,188,191,195,198,201,205,208,211,214,217,221,224,227,231,234,237,241,244,247],[39,40,41],"p",{},"The measure of a data table is not whether it displays the data. It is whether people stop exporting it.",[39,43,44,45,49],{},"Every dense-data product we have worked on had the same tell: the most-clicked control was ",[46,47,48],"strong",{},"Export to CSV",". That button is a review. It means the table can show the data but cannot support the work, so people take it somewhere that can.",[51,52,54],"h2",{"id":53},"row-height-is-the-whole-argument","Row height is the whole argument",[39,56,57],{},"Everything about table density comes down to one number, and it is contested for good reason. Operators want more rows on screen. Readability wants fewer.",[39,59,60],{},"What we have landed on after several of these:",[62,63,64,71,77],"ul",{},[65,66,67,70],"li",{},[46,68,69],{},"36px default."," Fits a 14px label with enough vertical breathing room to scan a column without losing your place.",[65,72,73,76],{},[46,74,75],{},"28px compact mode."," Available, not default. Power users find it; nobody is forced into it.",[65,78,79,82],{},[46,80,81],{},"Never below 24px."," Below that, horizontal scanning breaks down — the eye loses the row on the way across.",[39,84,85],{},"The compact mode matters more than the exact default. Density preference correlates with how long someone has used the product, and a single fixed height is wrong for one group or the other.",[51,87,89],{"id":88},"alignment-carries-meaning","Alignment carries meaning",[39,91,92],{},"Column alignment is not aesthetic. It is how the eye knows what kind of value it is looking at.",[62,94,95,101,120,126],{},[65,96,97,100],{},[46,98,99],{},"Numerics right-aligned, always."," Digits line up by place value, and a column of right-aligned numbers can be compared without reading any of them.",[65,102,103,106,107,111,112,115,116,119],{},[46,104,105],{},"Tabular figures, always."," ",[108,109,110],"code",{},"font-variant-numeric: tabular-nums",". Without it, proportional digits make ",[108,113,114],{},"1,111"," narrower than ",[108,117,118],{},"8,888"," and the column stops lining up even when right-aligned.",[65,121,122,125],{},[46,123,124],{},"Identifiers monospaced."," Order numbers, SKUs, hashes. Fixed width makes a transposed character visible.",[65,127,128,131],{},[46,129,130],{},"Text left-aligned, never centred."," Centred text has no consistent starting edge, so every row costs a fresh visual search.",[133,134,139],"pre",{"className":135,"code":136,"language":137,"meta":138,"style":138},"language-css shiki shiki-themes github-dark-dimmed github-dark-dimmed",".cell-numeric {\n  text-align: right;\n  font-variant-numeric: tabular-nums;\n}\n","css","",[108,140,141,154,169,182],{"__ignoreMap":138},[142,143,146,150],"span",{"class":144,"line":145},"line",1,[142,147,149],{"class":148},"sQdni",".cell-numeric",[142,151,153],{"class":152},"sM9_K"," {\n",[142,155,157,160,163,166],{"class":144,"line":156},2,[142,158,159],{"class":148},"  text-align",[142,161,162],{"class":152},": ",[142,164,165],{"class":148},"right",[142,167,168],{"class":152},";\n",[142,170,172,175,177,180],{"class":144,"line":171},3,[142,173,174],{"class":148},"  font-variant-numeric",[142,176,162],{"class":152},[142,178,179],{"class":148},"tabular-nums",[142,181,168],{"class":152},[142,183,185],{"class":144,"line":184},4,[142,186,187],{"class":152},"}\n",[39,189,190],{},"Two lines of CSS, and it is the single most noticeable improvement you can make to a data table.",[51,192,194],{"id":193},"status-needs-a-glyph-and-a-word","Status needs a glyph and a word",[39,196,197],{},"Colour-only status fails for the eight percent of men with a colour vision deficiency, and it fails for everyone in a screenshot pasted into Slack at low contrast.",[39,199,200],{},"Pair a shape with a label. A filled dot plus \"Delayed\" survives greyscale, low contrast, and being described over a phone call. Colour then becomes reinforcement rather than the carrier.",[51,202,204],{"id":203},"the-filter-model-decides-everything","The filter model decides everything",[39,206,207],{},"Most table redesigns fail here rather than in the visual layer.",[39,209,210],{},"A single-select filter bar handles \"show me late shipments.\" Real questions are compound: \"late shipments from Rotterdam, excluding the two carriers in a weather delay.\" If the UI cannot express that, users export.",[39,212,213],{},"Stackable conditions — field, operator, value, joined with and\u002For — are more work to build and are the difference between a table people use and a table people download.",[39,215,216],{},"The other half is latency. If filtering costs a server round-trip, people stop exploring and start planning each query in advance, which is a completely different and much worse mode of work. Client-side filtering over a loaded window gets you to sub-50ms, and below that threshold people start poking at the data. That shift is the point of the whole exercise.",[51,218,220],{"id":219},"saved-views-are-the-retention-feature","Saved views are the retention feature",[39,222,223],{},"Every operator has four or five recurring questions. If the table forgets its state on refresh, they rebuild the same view every morning, and the product feels like a tool they are fighting.",[39,225,226],{},"Named, pinnable views fix that. The unplanned benefit, every time we have shipped this, is that teams build a shared set of standard views and use them to onboard new hires. The feature turns into documentation.",[51,228,230],{"id":229},"virtualise-but-settle-density-first","Virtualise, but settle density first",[39,232,233],{},"Row virtualisation makes row count irrelevant, and you will need it above a few thousand rows.",[39,235,236],{},"Do it in the right order, though. Virtualisers want a known row height, so locking the rendering strategy before the density decision means re-validating every height choice against the virtualiser's assumptions. Decide the height, then virtualise.",[51,238,240],{"id":239},"the-metric","The metric",[39,242,243],{},"Not time on page. Not rows viewed.",[39,245,246],{},"Count CSV exports per user per week, and watch it go down. That number is the honest measure of whether people trust the interface enough to work inside it.",[248,249,250],"style",{},"html pre.shiki code .sQdni, html code.shiki .sQdni{--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .sM9_K, html code.shiki .sM9_K{--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":138,"searchDepth":171,"depth":171,"links":252},[253,254,255,256,257,258,259],{"id":53,"depth":156,"text":54},{"id":88,"depth":156,"text":89},{"id":193,"depth":156,"text":194},{"id":203,"depth":156,"text":204},{"id":219,"depth":156,"text":220},{"id":229,"depth":156,"text":230},{"id":239,"depth":156,"text":240},"design","\u002Fcovers\u002Fblog\u002Fen\u002Fdense-tables-that-stay-readable.webp","2026-03-14","Row height, alignment, tabular figures and the filter model. What actually makes a 40,000-row table usable rather than merely displayable.","md","en",{},"\u002Fblog\u002Fen\u002Fdense-tables-that-stay-readable",6,{"title":31,"description":263},"blog\u002Fen\u002Fdense-tables-that-stay-readable",[272,273,274],"Data Visualisation","Dashboards","Tables","5Ny3y4HIkpKmySi2ICH8ig3RzEm527WowyL5qT7c7vI",[277,920,1082],{"id":278,"title":279,"author":280,"body":283,"category":907,"cover":908,"coverAlt":279,"date":909,"description":910,"draft":6,"extension":264,"featured":24,"lang":265,"meta":911,"navigation":24,"path":912,"readingTime":351,"seo":913,"stem":914,"tags":915,"__hash__":919},"blog\u002Fblog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand.md","Design tokens that survive a rebrand",{"name":281,"role":282},"Elena Marsh","Design Systems Lead",{"type":36,"value":284,"toc":899},[285,288,292,295,391,394,408,414,418,430,722,725,736,740,743,798,802,813,844,865,873,877,880,886,889,893,896],[39,286,287],{},"A design system survives its first rebrand or it does not. Most do not, and the reason is almost always the same: the system has two layers where it needs three.",[51,289,291],{"id":290},"the-two-layer-trap","The two-layer trap",[39,293,294],{},"Here is the shape nearly every token system starts with. A palette, and components that consume it.",[133,296,298],{"className":135,"code":297,"language":137,"meta":138,"style":138},":root {\n  --blue-500: #00d4ff;\n  --gray-900: #18181b;\n}\n\n.card {\n  background: var(--gray-900);\n  border-color: var(--blue-500);\n}\n",[108,299,300,307,320,332,336,342,349,369,386],{"__ignoreMap":138},[142,301,302,305],{"class":144,"line":145},[142,303,304],{"class":148},":root",[142,306,153],{"class":152},[142,308,309,313,315,318],{"class":144,"line":156},[142,310,312],{"class":311},"siJwO","  --blue-500",[142,314,162],{"class":152},[142,316,317],{"class":148},"#00d4ff",[142,319,168],{"class":152},[142,321,322,325,327,330],{"class":144,"line":171},[142,323,324],{"class":311},"  --gray-900",[142,326,162],{"class":152},[142,328,329],{"class":148},"#18181b",[142,331,168],{"class":152},[142,333,334],{"class":144,"line":184},[142,335,187],{"class":152},[142,337,339],{"class":144,"line":338},5,[142,340,341],{"emptyLinePlaceholder":24},"\n",[142,343,344,347],{"class":144,"line":268},[142,345,346],{"class":148},".card",[142,348,153],{"class":152},[142,350,352,355,357,360,363,366],{"class":144,"line":351},7,[142,353,354],{"class":148},"  background",[142,356,162],{"class":152},[142,358,359],{"class":148},"var",[142,361,362],{"class":152},"(",[142,364,365],{"class":311},"--gray-900",[142,367,368],{"class":152},");\n",[142,370,372,375,377,379,381,384],{"class":144,"line":371},8,[142,373,374],{"class":148},"  border-color",[142,376,162],{"class":152},[142,378,359],{"class":148},[142,380,362],{"class":152},[142,382,383],{"class":311},"--blue-500",[142,385,368],{"class":152},[142,387,389],{"class":144,"line":388},9,[142,390,187],{"class":152},[39,392,393],{},"This is already better than hardcoded hexes, and it feels like the job is done. It is not, and the failure mode is specific.",[39,395,396,397,399,400,403,404,407],{},"Six months in, someone asks for a light theme. ",[108,398,365],{}," is the card background in dark mode and needs to be near-white in light mode. So you either rename the variable to something that is now a lie, or you write ",[108,401,402],{},"--gray-900: #ffffff"," under ",[108,405,406],{},"[data-theme=\"light\"]",", which is worse.",[39,409,410,411,413],{},"Then the rebrand lands, blue becomes violet, and you discover ",[108,412,383],{}," is referenced in 340 places — some of which meant \"our brand colour\" and some of which meant \"the colour of a link\" and some of which meant \"this particular chart series.\" Those are three different concepts that happened to share a hex.",[51,415,417],{"id":416},"the-layer-you-are-missing","The layer you are missing",[39,419,420,421,425,426,429],{},"Primitives describe what a value ",[422,423,424],"em",{},"is",". Semantics describe what it is ",[422,427,428],{},"for",". You need both, and components may only touch the second.",[133,431,433],{"className":135,"code":432,"language":137,"meta":138,"style":138},"\u002F* 1. Primitives — raw values, no product meaning *\u002F\n:root {\n  --cyan-500: #00d4ff;\n  --neutral-900: #18181b;\n  --neutral-0: #ffffff;\n}\n\n\u002F* 2. Semantics — roles, which flip per theme *\u002F\n:root,\n[data-theme='dark'] {\n  --surface: var(--neutral-900);\n  --primary: var(--cyan-500);\n  --border: rgb(255 255 255 \u002F 0.08);\n}\n\n[data-theme='light'] {\n  --surface: var(--neutral-0);\n  --primary: #0093b8;\n  --border: rgb(9 9 11 \u002F 0.09);\n}\n\n\u002F* 3. Components — roles only *\u002F\n.card {\n  background: var(--surface);\n  border-color: var(--border);\n}\n",[108,434,435,441,447,458,469,481,485,489,494,501,521,538,555,584,589,594,608,624,636,663,668,673,679,686,701,717],{"__ignoreMap":138},[142,436,437],{"class":144,"line":145},[142,438,440],{"class":439},"s0RzX","\u002F* 1. Primitives — raw values, no product meaning *\u002F\n",[142,442,443,445],{"class":144,"line":156},[142,444,304],{"class":148},[142,446,153],{"class":152},[142,448,449,452,454,456],{"class":144,"line":171},[142,450,451],{"class":311},"  --cyan-500",[142,453,162],{"class":152},[142,455,317],{"class":148},[142,457,168],{"class":152},[142,459,460,463,465,467],{"class":144,"line":184},[142,461,462],{"class":311},"  --neutral-900",[142,464,162],{"class":152},[142,466,329],{"class":148},[142,468,168],{"class":152},[142,470,471,474,476,479],{"class":144,"line":338},[142,472,473],{"class":311},"  --neutral-0",[142,475,162],{"class":152},[142,477,478],{"class":148},"#ffffff",[142,480,168],{"class":152},[142,482,483],{"class":144,"line":268},[142,484,187],{"class":152},[142,486,487],{"class":144,"line":351},[142,488,341],{"emptyLinePlaceholder":24},[142,490,491],{"class":144,"line":371},[142,492,493],{"class":439},"\u002F* 2. Semantics — roles, which flip per theme *\u002F\n",[142,495,496,498],{"class":144,"line":388},[142,497,304],{"class":148},[142,499,500],{"class":152},",\n",[142,502,504,507,510,514,518],{"class":144,"line":503},10,[142,505,506],{"class":152},"[",[142,508,509],{"class":148},"data-theme",[142,511,513],{"class":512},"sEYkB","=",[142,515,517],{"class":516},"szYpP","'dark'",[142,519,520],{"class":152},"] {\n",[142,522,524,527,529,531,533,536],{"class":144,"line":523},11,[142,525,526],{"class":311},"  --surface",[142,528,162],{"class":152},[142,530,359],{"class":148},[142,532,362],{"class":152},[142,534,535],{"class":311},"--neutral-900",[142,537,368],{"class":152},[142,539,541,544,546,548,550,553],{"class":144,"line":540},12,[142,542,543],{"class":311},"  --primary",[142,545,162],{"class":152},[142,547,359],{"class":148},[142,549,362],{"class":152},[142,551,552],{"class":311},"--cyan-500",[142,554,368],{"class":152},[142,556,558,561,563,566,568,571,574,576,579,582],{"class":144,"line":557},13,[142,559,560],{"class":311},"  --border",[142,562,162],{"class":152},[142,564,565],{"class":148},"rgb",[142,567,362],{"class":152},[142,569,570],{"class":148},"255",[142,572,573],{"class":148}," 255",[142,575,573],{"class":148},[142,577,578],{"class":152}," \u002F ",[142,580,581],{"class":148},"0.08",[142,583,368],{"class":152},[142,585,587],{"class":144,"line":586},14,[142,588,187],{"class":152},[142,590,592],{"class":144,"line":591},15,[142,593,341],{"emptyLinePlaceholder":24},[142,595,597,599,601,603,606],{"class":144,"line":596},16,[142,598,506],{"class":152},[142,600,509],{"class":148},[142,602,513],{"class":512},[142,604,605],{"class":516},"'light'",[142,607,520],{"class":152},[142,609,611,613,615,617,619,622],{"class":144,"line":610},17,[142,612,526],{"class":311},[142,614,162],{"class":152},[142,616,359],{"class":148},[142,618,362],{"class":152},[142,620,621],{"class":311},"--neutral-0",[142,623,368],{"class":152},[142,625,627,629,631,634],{"class":144,"line":626},18,[142,628,543],{"class":311},[142,630,162],{"class":152},[142,632,633],{"class":148},"#0093b8",[142,635,168],{"class":152},[142,637,639,641,643,645,647,650,653,656,658,661],{"class":144,"line":638},19,[142,640,560],{"class":311},[142,642,162],{"class":152},[142,644,565],{"class":148},[142,646,362],{"class":152},[142,648,649],{"class":148},"9",[142,651,652],{"class":148}," 9",[142,654,655],{"class":148}," 11",[142,657,578],{"class":152},[142,659,660],{"class":148},"0.09",[142,662,368],{"class":152},[142,664,666],{"class":144,"line":665},20,[142,667,187],{"class":152},[142,669,671],{"class":144,"line":670},21,[142,672,341],{"emptyLinePlaceholder":24},[142,674,676],{"class":144,"line":675},22,[142,677,678],{"class":439},"\u002F* 3. Components — roles only *\u002F\n",[142,680,682,684],{"class":144,"line":681},23,[142,683,346],{"class":148},[142,685,153],{"class":152},[142,687,688,690,692,694,696,699],{"class":144,"line":5},[142,689,354],{"class":148},[142,691,162],{"class":152},[142,693,359],{"class":148},[142,695,362],{"class":152},[142,697,698],{"class":311},"--surface",[142,700,368],{"class":152},[142,702,704,706,708,710,712,715],{"class":144,"line":703},25,[142,705,374],{"class":148},[142,707,162],{"class":152},[142,709,359],{"class":148},[142,711,362],{"class":152},[142,713,714],{"class":311},"--border",[142,716,368],{"class":152},[142,718,720],{"class":144,"line":719},26,[142,721,187],{"class":152},[39,723,724],{},"Now the light theme is a block of overrides in one place, and the rebrand is an edit to the semantic layer. The 340 call sites do not move, because they were never asking for blue. They were asking for \"primary,\" and primary is now violet.",[39,726,727,728,731,732,735],{},"Note also that ",[108,729,730],{},"--primary"," is not the same hex in both themes. ",[108,733,734],{},"#00D4FF"," on white fails contrast badly; the light theme needs a darker cyan to clear WCAG AA. A two-layer system cannot express that without a conditional at every call site. A three-layer system expresses it once.",[51,737,739],{"id":738},"naming-the-semantic-layer","Naming the semantic layer",[39,741,742],{},"The semantic layer is only useful if the names describe roles rather than appearance. Some rules that hold up:",[62,744,745,765,771,788],{},[65,746,747,106,750,753,754,757,758,753,761,764],{},[46,748,749],{},"Name the job, not the look.",[108,751,752],{},"--surface-raised",", not ",[108,755,756],{},"--gray-800",". ",[108,759,760],{},"--danger",[108,762,763],{},"--red",".",[65,766,767,770],{},[46,768,769],{},"Pair foreground with background."," Every surface token gets a matching foreground token, checked for contrast once, so no component has to think about it.",[65,772,773,106,776,779,780,783,784,787],{},[46,774,775],{},"Encode state in the name.",[108,777,778],{},"--surface-hover",", ",[108,781,782],{},"--surface-active",". If hover is derived at the call site with ",[108,785,786],{},"color-mix"," or an opacity modifier, you have leaked appearance logic back into components.",[65,789,790,793,794,797],{},[46,791,792],{},"Resist the urge to add a third brand colour."," Most systems that end up with ",[108,795,796],{},"--tertiary"," did so because someone needed a specific shade once. That is what a one-off class is for.",[51,799,801],{"id":800},"wiring-it-to-tailwind-v4","Wiring it to Tailwind v4",[39,803,804,805,808,809,812],{},"Tailwind v4's ",[108,806,807],{},"@theme"," generates utilities from CSS variables, and it has an ",[108,810,811],{},"inline"," variant that matters here.",[133,814,816],{"className":135,"code":815,"language":137,"meta":138,"style":138},"@theme inline {\n  --color-surface: var(--surface);\n  --color-primary: var(--primary);\n  --color-border: var(--border);\n}\n",[108,817,818,825,830,835,840],{"__ignoreMap":138},[142,819,820,822],{"class":144,"line":145},[142,821,807],{"class":512},[142,823,824],{"class":152}," inline {\n",[142,826,827],{"class":144,"line":156},[142,828,829],{"class":152},"  --color-surface: var(--surface);\n",[142,831,832],{"class":144,"line":171},[142,833,834],{"class":152},"  --color-primary: var(--primary);\n",[142,836,837],{"class":144,"line":184},[142,838,839],{"class":152},"  --color-border: var(--border);\n",[142,841,842],{"class":144,"line":338},[142,843,187],{"class":152},[39,845,846,849,850,853,854,857,858,861,862,864],{},[108,847,848],{},"@theme inline"," keeps the ",[108,851,852],{},"var()"," reference live in the generated utility, so ",[108,855,856],{},"bg-surface"," emits ",[108,859,860],{},"background-color: var(--surface)"," and re-resolves when the theme attribute changes. Without ",[108,863,811],{},", Tailwind resolves the value at build time and your theme switch silently does nothing — a bug that is genuinely unpleasant to track down because the CSS looks correct.",[39,866,867,868,870,871,764],{},"Static scales that never flip — spacing, radius, duration, easing — go in a plain ",[108,869,807],{}," block. Only the colour roles need ",[108,872,811],{},[51,874,876],{"id":875},"the-test","The test",[39,878,879],{},"There is one question that tells you whether your token layer is real:",[881,882,883],"blockquote",{},[39,884,885],{},"If the brand colour changed tomorrow, how many files would you edit?",[39,887,888],{},"If the answer is more than one, you have a naming convention, not an architecture. The gap between those two is roughly a day of work, and it is the difference between a rebrand being a variable change and a rebrand being a quarter.",[51,890,892],{"id":891},"where-this-stops-being-worth-it","Where this stops being worth it",[39,894,895],{},"For a marketing site with six components, three layers is overhead. The break-even is somewhere around the point where you have a second theme, a second product, or a second team — whichever arrives first. Below that, a flat palette is fine and you should not let anyone tell you otherwise.",[248,897,898],{},"html pre.shiki code .sQdni, html code.shiki .sQdni{--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .sM9_K, html code.shiki .sM9_K{--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .siJwO, html code.shiki .siJwO{--shiki-default:#F69D50;--shiki-dark:#F69D50}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s0RzX, html code.shiki .s0RzX{--shiki-default:#768390;--shiki-dark:#768390}html pre.shiki code .sEYkB, html code.shiki .sEYkB{--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .szYpP, html code.shiki .szYpP{--shiki-default:#96D0FF;--shiki-dark:#96D0FF}",{"title":138,"searchDepth":171,"depth":171,"links":900},[901,902,903,904,905,906],{"id":290,"depth":156,"text":291},{"id":416,"depth":156,"text":417},{"id":738,"depth":156,"text":739},{"id":800,"depth":156,"text":801},{"id":875,"depth":156,"text":876},{"id":891,"depth":156,"text":892},"design-systems","\u002Fcovers\u002Fblog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand.webp","2026-06-18","Most token systems break the first time the brand changes. The fix is a layer most teams skip — and it costs about a day to add.",{},"\u002Fblog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand",{"title":279,"description":910},"blog\u002Fen\u002Fdesign-tokens-that-survive-a-rebrand",[916,917,918],"Design Tokens","Architecture","Tailwind","7dPa213uoSmziFZ1uEs730NPYoCBRpGHOQQ_ovl_Bs0",{"id":921,"title":922,"author":923,"body":924,"category":1069,"cover":1070,"coverAlt":922,"date":1071,"description":1072,"draft":6,"extension":264,"featured":24,"lang":265,"meta":1073,"navigation":24,"path":1074,"readingTime":371,"seo":1075,"stem":1076,"tags":1077,"__hash__":1081},"blog\u002Fblog\u002Fen\u002Fdesigning-for-models-that-are-wrong.md","Designing for models that are wrong",{"name":33,"role":34},{"type":36,"value":925,"toc":1059},[926,929,932,936,939,942,947,953,956,960,963,966,986,989,993,996,1003,1007,1010,1013,1033,1037,1043,1046,1049,1053,1056],[39,927,928],{},"A model with 94% accuracy is wrong roughly once every sixteen times. If your interface presents every output with identical confidence, users will meet that sixteenth case, and from then on they will discount all sixteen.",[39,930,931],{},"This is the central design problem in AI products, and it is not solved by making the model better. A 99% model is wrong once every hundred times, and a user who cannot tell which time it is still has to check all hundred.",[51,933,935],{"id":934},"uncertainty-is-a-design-surface","Uncertainty is a design surface",[39,937,938],{},"The useful question is not \"how do we hide that the model is uncertain\" but \"how do we put uncertainty where the user can act on it.\"",[39,940,941],{},"A few patterns that hold up in practice.",[943,944,946],"h3",{"id":945},"show-the-evidence-next-to-the-conclusion","Show the evidence next to the conclusion",[39,948,949,950,764],{},"An answer alone is unverifiable. An answer with its sources is checkable in seconds. This is why citation-first interfaces outperform confident-summary interfaces even when the summaries are equally accurate — the user is not reading the citation, they are reading the ",[422,951,952],{},"fact that there is one",[39,954,955],{},"The cheap version of this is a link. The good version highlights the specific span of the source that produced the claim, so verification costs a glance rather than a page of reading.",[943,957,959],{"id":958},"bands-beat-percentages","Bands beat percentages",[39,961,962],{},"\"87% confident\" reads as false precision to most people, and as noise to the rest. In user testing we consistently see people round anything above 80% to \"yes\" and anything below to \"no,\" which discards the entire signal.",[39,964,965],{},"Calibrated bands work better because they carry a meaning rather than a number:",[62,967,968,974,980],{},[65,969,970,973],{},[46,971,972],{},"Strong"," — many similar cases in training, historically reliable here",[65,975,976,979],{},[46,977,978],{},"Mixed"," — features disagree, worth a closer look",[65,981,982,985],{},[46,983,984],{},"Sparse"," — little comparable data, treat as a prompt rather than an answer",[39,987,988],{},"The third band is the one teams resist shipping and the one users value most. Admitting the model is out of its depth is what makes the other two bands credible.",[943,990,992],{"id":991},"make-disagreement-cheap","Make disagreement cheap",[39,994,995],{},"If accepting a suggestion is one click and rejecting it is a modal with a required reason code, you have not built a feedback loop. You have built a compliance funnel, and the data coming out of it is worthless.",[39,997,998,999,1002],{},"Symmetrical cost is the rule: rejecting should take exactly as long as accepting. Ask for a reason, make it optional, and accept free text. You will get ",[422,1000,1001],{},"more"," structured feedback this way, not less, because people who were previously avoiding the interaction entirely will now engage with it.",[51,1004,1006],{"id":1005},"latency-is-a-trust-signal","Latency is a trust signal",[39,1008,1009],{},"Streaming changed what users expect, but it also created a new failure mode: an interface that streams confidently and then contradicts itself three sentences in.",[39,1011,1012],{},"Some rules we apply:",[62,1014,1015,1021,1027],{},[65,1016,1017,1020],{},[46,1018,1019],{},"Never stream a verdict you might retract."," Stream the reasoning, then commit to the conclusion. Reversing a rendered answer is worse than a slower answer.",[65,1022,1023,1026],{},[46,1024,1025],{},"Show the shape of the wait."," \"Searching 1,240 documents\" beats a spinner, not because it is faster but because it is falsifiable — the user can tell whether the system is doing something plausible.",[65,1028,1029,1032],{},[46,1030,1031],{},"Budget for the p95, not the median."," AI features have long tails. An interface that feels good at 800ms and broken at 6s will be remembered as broken.",[51,1034,1036],{"id":1035},"the-interaction-nobody-designs","The interaction nobody designs",[39,1038,1039,1040],{},"Almost every AI feature ships without a designed answer to: ",[422,1041,1042],{},"what happens after the user notices it was wrong?",[39,1044,1045],{},"Not the error state — the model returning something plausible and incorrect is not an error, it is normal operation. The question is whether the user has a path from \"this is wrong\" to \"the system knows it was wrong and I can move on.\"",[39,1047,1048],{},"Without that path, the user's only option is to stop using the feature. Which is what most of them do, quietly, and it does not show up as an error in any dashboard.",[51,1050,1052],{"id":1051},"what-this-costs","What this costs",[39,1054,1055],{},"Designing for uncertainty makes the happy path slightly worse. Citations take space. Bands are less satisfying than a big number. An easy reject button will be used.",[39,1057,1058],{},"That trade is worth making, because the alternative is not a cleaner interface — it is a feature users abandon after the first time it burns them. A product that is honest about being wrong 6% of the time gets used for the other 94%. A product that pretends to be certain gets used until the sixteenth case, and then not at all.",{"title":138,"searchDepth":171,"depth":171,"links":1060},[1061,1066,1067,1068],{"id":934,"depth":156,"text":935,"children":1062},[1063,1064,1065],{"id":945,"depth":171,"text":946},{"id":958,"depth":171,"text":959},{"id":991,"depth":171,"text":992},{"id":1005,"depth":156,"text":1006},{"id":1035,"depth":156,"text":1036},{"id":1051,"depth":156,"text":1052},"ai","\u002Fcovers\u002Fblog\u002Fen\u002Fdesigning-for-models-that-are-wrong.webp","2026-05-02","Every AI feature has a failure rate. Most interfaces are designed as though it were zero, and users learn to distrust the whole product.",{},"\u002Fblog\u002Fen\u002Fdesigning-for-models-that-are-wrong",{"title":922,"description":1072},"blog\u002Fen\u002Fdesigning-for-models-that-are-wrong",[1078,1079,1080],"AI","Product Design","Trust","MBTX59kM1xlP_eNQBxC5t-JS5qQhe3ZkMfkjV-vlp0U",{"id":1083,"title":1084,"author":1085,"body":1088,"category":1601,"cover":1602,"coverAlt":1084,"date":1603,"description":1604,"draft":6,"extension":264,"featured":6,"lang":265,"meta":1605,"navigation":24,"path":1606,"readingTime":268,"seo":1607,"stem":1608,"tags":1609,"__hash__":1613},"blog\u002Fblog\u002Fen\u002Fcomponent-api-design.md","Your component API is a contract, so write it down",{"name":1086,"role":1087},"Sofia Lindqvist","Staff Frontend Engineer",{"type":36,"value":1089,"toc":1592},[1090,1093,1104,1107,1111,1114,1260,1267,1270,1274,1286,1289,1354,1367,1371,1374,1387,1399,1403,1406,1470,1481,1484,1519,1523,1529,1542,1560,1564,1567,1578,1580,1583,1589],[39,1091,1092],{},"A component library fails in a predictable way. Not all at once — one prop at a time.",[39,1094,1095,1096,1099,1100,1103],{},"Someone needs a slightly different button. They add ",[108,1097,1098],{},"isCompact",". Someone else needs a different one and adds ",[108,1101,1102],{},"variant=\"small\"",". Six months later the component takes fourteen props, four of which interact in ways nobody has documented, and everyone has quietly gone back to writing their own.",[39,1105,1106],{},"The fix is to treat the component's visual API as a contract that is written down in one place and enforced.",[51,1108,1110],{"id":1109},"the-variant-table","The variant table",[39,1112,1113],{},"Every component should declare its full surface as data, not as branching logic scattered through the template.",[133,1115,1119],{"className":1116,"code":1117,"language":1118,"meta":138,"style":138},"language-ts shiki shiki-themes github-dark-dimmed github-dark-dimmed","const button = tv({\n  base: 'inline-flex items-center justify-center font-medium',\n  variants: {\n    variant: {\n      primary: 'bg-primary text-primary-foreground',\n      outline: 'border border-border-strong bg-transparent',\n      ghost: 'text-foreground-secondary hover:bg-surface-hover',\n    },\n    size: {\n      sm: 'h-9 px-3.5 text-sm',\n      md: 'h-11 px-5 text-sm',\n      lg: 'h-12 px-6 text-md',\n    },\n  },\n  defaultVariants: { variant: 'primary', size: 'md' },\n})\n","ts",[108,1120,1121,1139,1149,1154,1159,1169,1179,1189,1194,1199,1209,1219,1229,1233,1238,1255],{"__ignoreMap":138},[142,1122,1123,1126,1129,1132,1136],{"class":144,"line":145},[142,1124,1125],{"class":512},"const",[142,1127,1128],{"class":148}," button",[142,1130,1131],{"class":512}," =",[142,1133,1135],{"class":1134},"s-wk_"," tv",[142,1137,1138],{"class":152},"({\n",[142,1140,1141,1144,1147],{"class":144,"line":156},[142,1142,1143],{"class":152},"  base: ",[142,1145,1146],{"class":516},"'inline-flex items-center justify-center font-medium'",[142,1148,500],{"class":152},[142,1150,1151],{"class":144,"line":171},[142,1152,1153],{"class":152},"  variants: {\n",[142,1155,1156],{"class":144,"line":184},[142,1157,1158],{"class":152},"    variant: {\n",[142,1160,1161,1164,1167],{"class":144,"line":338},[142,1162,1163],{"class":152},"      primary: ",[142,1165,1166],{"class":516},"'bg-primary text-primary-foreground'",[142,1168,500],{"class":152},[142,1170,1171,1174,1177],{"class":144,"line":268},[142,1172,1173],{"class":152},"      outline: ",[142,1175,1176],{"class":516},"'border border-border-strong bg-transparent'",[142,1178,500],{"class":152},[142,1180,1181,1184,1187],{"class":144,"line":351},[142,1182,1183],{"class":152},"      ghost: ",[142,1185,1186],{"class":516},"'text-foreground-secondary hover:bg-surface-hover'",[142,1188,500],{"class":152},[142,1190,1191],{"class":144,"line":371},[142,1192,1193],{"class":152},"    },\n",[142,1195,1196],{"class":144,"line":388},[142,1197,1198],{"class":152},"    size: {\n",[142,1200,1201,1204,1207],{"class":144,"line":503},[142,1202,1203],{"class":152},"      sm: ",[142,1205,1206],{"class":516},"'h-9 px-3.5 text-sm'",[142,1208,500],{"class":152},[142,1210,1211,1214,1217],{"class":144,"line":523},[142,1212,1213],{"class":152},"      md: ",[142,1215,1216],{"class":516},"'h-11 px-5 text-sm'",[142,1218,500],{"class":152},[142,1220,1221,1224,1227],{"class":144,"line":540},[142,1222,1223],{"class":152},"      lg: ",[142,1225,1226],{"class":516},"'h-12 px-6 text-md'",[142,1228,500],{"class":152},[142,1230,1231],{"class":144,"line":557},[142,1232,1193],{"class":152},[142,1234,1235],{"class":144,"line":586},[142,1236,1237],{"class":152},"  },\n",[142,1239,1240,1243,1246,1249,1252],{"class":144,"line":591},[142,1241,1242],{"class":152},"  defaultVariants: { variant: ",[142,1244,1245],{"class":516},"'primary'",[142,1247,1248],{"class":152},", size: ",[142,1250,1251],{"class":516},"'md'",[142,1253,1254],{"class":152}," },\n",[142,1256,1257],{"class":144,"line":596},[142,1258,1259],{"class":152},"})\n",[39,1261,1262,1263,1266],{},"The value here is not the terseness. It is that the table is exhaustive. If a variant is not in it, it does not exist, and a request for a new one is a visible decision rather than an inline ",[108,1264,1265],{},"class"," override that nobody reviews.",[39,1268,1269],{},"This is the single highest-leverage thing you can do to stop a library from decaying.",[51,1271,1273],{"id":1272},"booleans-are-enums-that-have-not-grown-up-yet","Booleans are enums that have not grown up yet",[39,1275,1276,1278,1279,1281,1282,1285],{},[108,1277,1098],{}," seems fine until you need a third density. Then you get ",[108,1280,1098],{}," and ",[108,1283,1284],{},"isSpacious"," and the undefined behaviour when both are true.",[39,1287,1288],{},"If a property has any chance of gaining a third value, make it an enum on day one:",[133,1290,1292],{"className":1116,"code":1291,"language":1118,"meta":138,"style":138},"\u002F\u002F Will break\ndefineProps\u003C{ isCompact?: boolean }>()\n\n\u002F\u002F Will not\ndefineProps\u003C{ density?: 'compact' | 'default' | 'comfortable' }>()\n",[108,1293,1294,1299,1318,1322,1327],{"__ignoreMap":138},[142,1295,1296],{"class":144,"line":145},[142,1297,1298],{"class":439},"\u002F\u002F Will break\n",[142,1300,1301,1304,1307,1309,1312,1315],{"class":144,"line":156},[142,1302,1303],{"class":1134},"defineProps",[142,1305,1306],{"class":152},"\u003C{ ",[142,1308,1098],{"class":311},[142,1310,1311],{"class":512},"?:",[142,1313,1314],{"class":148}," boolean",[142,1316,1317],{"class":152}," }>()\n",[142,1319,1320],{"class":144,"line":171},[142,1321,341],{"emptyLinePlaceholder":24},[142,1323,1324],{"class":144,"line":184},[142,1325,1326],{"class":439},"\u002F\u002F Will not\n",[142,1328,1329,1331,1333,1336,1338,1341,1344,1347,1349,1352],{"class":144,"line":338},[142,1330,1303],{"class":1134},[142,1332,1306],{"class":152},[142,1334,1335],{"class":311},"density",[142,1337,1311],{"class":512},[142,1339,1340],{"class":516}," 'compact'",[142,1342,1343],{"class":512}," |",[142,1345,1346],{"class":516}," 'default'",[142,1348,1343],{"class":512},[142,1350,1351],{"class":516}," 'comfortable'",[142,1353,1317],{"class":152},[39,1355,1356,1357,779,1360,779,1363,1366],{},"Booleans are correct for genuinely binary states — ",[108,1358,1359],{},"disabled",[108,1361,1362],{},"loading",[108,1364,1365],{},"required",". Everything describing appearance is a candidate for growth.",[51,1368,1370],{"id":1369},"name-props-after-what-they-do","Name props after what they do",[39,1372,1373],{},"Two habits that pay for themselves:",[39,1375,1376,106,1379,1382,1383,1386],{},[46,1377,1378],{},"Name the role, not the implementation.",[108,1380,1381],{},"trailingIcon"," survives a switch from an icon font to inline SVG. ",[108,1384,1385],{},"iconRightSvg"," does not.",[39,1388,1389,1392,1393,1395,1396,1398],{},[46,1390,1391],{},"Match the platform where one exists."," If the DOM calls it ",[108,1394,1359],{},", call it ",[108,1397,1359],{},". Every renamed standard property is a thing every consumer has to learn.",[51,1400,1402],{"id":1401},"slots-for-content-props-for-configuration","Slots for content, props for configuration",[39,1404,1405],{},"The rule that keeps components from growing forever: if it is content, it belongs in a slot; if it changes behaviour or appearance, it is a prop.",[133,1407,1411],{"className":1408,"code":1409,"language":1410,"meta":138,"style":138},"language-vue shiki shiki-themes github-dark-dimmed github-dark-dimmed","\u003CUiButton variant=\"primary\" size=\"lg\">\n  \u003Ctemplate #leading>\n    \u003CAvatar :src=\"user.avatar\" size=\"xs\" \u002F>\n  \u003C\u002Ftemplate>\n  Continue as {{ user.name }}\n\u003C\u002FUiButton>\n","vue",[108,1412,1413,1441,1446,1451,1456,1461],{"__ignoreMap":138},[142,1414,1415,1418,1422,1425,1427,1430,1433,1435,1438],{"class":144,"line":145},[142,1416,1417],{"class":152},"\u003C",[142,1419,1421],{"class":1420},"sza-u","UiButton",[142,1423,1424],{"class":148}," variant",[142,1426,513],{"class":152},[142,1428,1429],{"class":516},"\"primary\"",[142,1431,1432],{"class":148}," size",[142,1434,513],{"class":152},[142,1436,1437],{"class":516},"\"lg\"",[142,1439,1440],{"class":152},">\n",[142,1442,1443],{"class":144,"line":156},[142,1444,1445],{"class":152},"  \u003Ctemplate #leading>\n",[142,1447,1448],{"class":144,"line":171},[142,1449,1450],{"class":152},"    \u003CAvatar :src=\"user.avatar\" size=\"xs\" \u002F>\n",[142,1452,1453],{"class":144,"line":184},[142,1454,1455],{"class":152},"  \u003C\u002Ftemplate>\n",[142,1457,1458],{"class":144,"line":338},[142,1459,1460],{"class":152},"  Continue as {{ user.name }}\n",[142,1462,1463,1466,1468],{"class":144,"line":268},[142,1464,1465],{"class":152},"\u003C\u002F",[142,1467,1421],{"class":1420},[142,1469,1440],{"class":152},[39,1471,1472,1473,1476,1477,1480],{},"Without the slot, someone eventually asks for an ",[108,1474,1475],{},"avatarSrc"," prop, and then an ",[108,1478,1479],{},"avatarSize"," prop, and the button is now responsible for rendering avatars.",[39,1482,1483],{},"Provide a default inside the slot so the common case stays one line:",[133,1485,1487],{"className":1408,"code":1486,"language":1410,"meta":138,"style":138},"\u003Cslot name=\"leading\">\n  \u003CIcon v-if=\"icon\" :name=\"icon\" \u002F>\n\u003C\u002Fslot>\n",[108,1488,1489,1506,1511],{"__ignoreMap":138},[142,1490,1491,1493,1496,1499,1501,1504],{"class":144,"line":145},[142,1492,1417],{"class":152},[142,1494,1495],{"class":1420},"slot",[142,1497,1498],{"class":148}," name",[142,1500,513],{"class":152},[142,1502,1503],{"class":516},"\"leading\"",[142,1505,1440],{"class":152},[142,1507,1508],{"class":144,"line":156},[142,1509,1510],{"class":152},"  \u003CIcon v-if=\"icon\" :name=\"icon\" \u002F>\n",[142,1512,1513,1515,1517],{"class":144,"line":171},[142,1514,1465],{"class":152},[142,1516,1495],{"class":1420},[142,1518,1440],{"class":152},[51,1520,1522],{"id":1521},"let-consumers-override-but-make-it-lose-predictably","Let consumers override, but make it lose predictably",[39,1524,1525,1526,1528],{},"A component that cannot be adjusted gets forked. A component that can be adjusted arbitrarily stops being a system. The middle path is to accept a ",[108,1527,1265],{}," prop, apply it last, and resolve conflicts deterministically:",[133,1530,1532],{"className":1116,"code":1531,"language":1118,"meta":138,"style":138},"cx(base, variantClasses, props.class)\n",[108,1533,1534],{"__ignoreMap":138},[142,1535,1536,1539],{"class":144,"line":145},[142,1537,1538],{"class":1134},"cx",[142,1540,1541],{"class":152},"(base, variantClasses, props.class)\n",[39,1543,1544,1545,1548,1549,1551,1552,1555,1556,1559],{},"With ",[108,1546,1547],{},"tailwind-merge"," in ",[108,1550,1538],{},", a consumer passing ",[108,1553,1554],{},"h-14"," beats the variant's ",[108,1557,1558],{},"h-11"," instead of both landing in the class list and the winner depending on Tailwind's internal ordering. Overrides become predictable, which means people use them for genuine one-offs rather than forking.",[51,1561,1563],{"id":1562},"accessibility-belongs-in-the-primitive","Accessibility belongs in the primitive",[39,1565,1566],{},"If focus rings, ARIA wiring and reduced-motion handling live in the consumer, they will be forgotten. Not sometimes — reliably, under deadline.",[39,1568,1569,1570,1573,1574,1577],{},"Put them in the primitive. A ",[108,1571,1572],{},"UiField"," that owns its own id, ",[108,1575,1576],{},"aria-describedby"," and error announcement means no form in the codebase can ship an undescribed input, because the wiring is not the consumer's job.",[51,1579,876],{"id":875},[39,1581,1582],{},"Ask someone who did not build the library to add a new page with it, and watch without helping.",[39,1584,1585,1586,1588],{},"Every time they open the component's source to work out what a prop does, that is a documentation failure. Every time they write a custom ",[108,1587,1265],{}," to get something the variant table should have offered, that is an API failure. Both are cheap to fix in week one and expensive in month nine.",[248,1590,1591],{},"html pre.shiki code .sEYkB, html code.shiki .sEYkB{--shiki-default:#F47067;--shiki-dark:#F47067}html pre.shiki code .sQdni, html code.shiki .sQdni{--shiki-default:#6CB6FF;--shiki-dark:#6CB6FF}html pre.shiki code .s-wk_, html code.shiki .s-wk_{--shiki-default:#DCBDFB;--shiki-dark:#DCBDFB}html pre.shiki code .sM9_K, html code.shiki .sM9_K{--shiki-default:#ADBAC7;--shiki-dark:#ADBAC7}html pre.shiki code .szYpP, html code.shiki .szYpP{--shiki-default:#96D0FF;--shiki-dark:#96D0FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s0RzX, html code.shiki .s0RzX{--shiki-default:#768390;--shiki-dark:#768390}html pre.shiki code .siJwO, html code.shiki .siJwO{--shiki-default:#F69D50;--shiki-dark:#F69D50}html pre.shiki code .sza-u, html code.shiki .sza-u{--shiki-default:#8DDB8C;--shiki-dark:#8DDB8C}",{"title":138,"searchDepth":171,"depth":171,"links":1593},[1594,1595,1596,1597,1598,1599,1600],{"id":1109,"depth":156,"text":1110},{"id":1272,"depth":156,"text":1273},{"id":1369,"depth":156,"text":1370},{"id":1401,"depth":156,"text":1402},{"id":1521,"depth":156,"text":1522},{"id":1562,"depth":156,"text":1563},{"id":875,"depth":156,"text":876},"engineering","\u002Fcovers\u002Fblog\u002Fen\u002Fcomponent-api-design.webp","2026-04-09","Variant tables, prop naming and the boolean that should have been an enum. Notes from maintaining component libraries other people have to use.",{},"\u002Fblog\u002Fen\u002Fcomponent-api-design",{"title":1084,"description":1604},"blog\u002Fen\u002Fcomponent-api-design",[1610,1611,1612],"Vue","Components","API Design","UGHo0vBRPnye5ZBaateSnSRWcf_erhdcP4Ws9hcrWWM",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1615},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"m9 18l6-6l-6-6\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1617},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M8 2v3m8-3v3\"\u002F>\u003Crect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\"\u002F>\u003Cpath d=\"M3 9h18\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1619},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"M12 6v6l4 2\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1621},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"\u002F>\u003Cpath d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1623},"\u003Cpath fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1625},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594zM20 2v4m2-2h-4\"\u002F>\u003Ccircle cx=\"4\" cy=\"20\" r=\"2\"\u002F>\u003C\u002Fg>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1627},"\u003Cg fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\">\u003Cpath d=\"M10 22V7a1 1 0 0 0-1-1H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a1 1 0 0 0-1-1H2\"\u002F>\u003Crect width=\"8\" height=\"8\" x=\"14\" y=\"2\" rx=\"1\"\u002F>\u003C\u002Fg>",1787597891875]