Changelog
Every change to the kit, newest first. The current version is 2026-10-22.2: the newest entry's date, and its count for that day.
Check for updates
Run this in your project to list every change since the version you have:
Line tabs for tabs inside a header
v2026-10-22.2
Tabs takes variant="line": plain labels over a 3px accent underline that
slides like the pill, with no track around them. It is for tabs that sit inside
other chrome, such as a DashboardCardHeader's action slot, where the framed
pill filled the 48px band. A line strip fills its parent's height and the
underline's last pixel covers the rule below, so in a card header it sits on
the border. DashboardCardHeader's action slot now spans the band's height to
allow that; its actions stay centred. Same sizes and counts; the selected label
is the foreground colour, and its count chip and bar are the plain accent in
both themes. Horizontal only; a vertical strip keeps the pill.
Segmented control and tab highlights sit evenly
v2026-10-22.1
The sliding highlight in SegmentedControl and Tabs is now measured to the
fraction of a pixel. It read its option's position and width as whole pixels,
which round apart, so the highlight landed up to half a pixel off the option it
covers and the gap on one side read wider than the other three. It is most
visible at 2x on the last option.
Moves on screen
- The selected option's highlight in
SegmentedControlandTabsshifts or resizes by up to half a pixel to match its option exactly.
A week schedule can fill its space
v2026-10-21.5
WeekSchedule takes fill: in a flex column with flex-1, or under a set
height, the hours stretch to fill what the page leaves, never shorter than
their default height, and the grid scrolls past that. Without it nothing
changes. The hour lines and labels are now placed as a share of the day, as the
events already were, so they stay in step with a stretched grid.
Select menus fit their options
v2026-10-21.4
A Select's menu is now as wide as its longest option, never narrower than its
trigger and never wider than the screen. It was pinned to the trigger's width,
so a short value such as "Share" squeezed "Own workflow" onto two lines, and
apps patched each menu with w-max min-w-(--anchor-width). That patch can come
out. A new warning, select-item-hand-rolled-trailing, flags side text (a tag,
a count, a muted second word) built into a SelectItem's children, where it
joins the label and runs into the check mark: use trailing.
Moves on screen
- Select menus whose options are longer than the trigger come out wider, with each option on one line. Menus whose options fit the trigger do not move.
New build failures
- None.
select-item-hand-rolled-trailinglands as a warning.
A dropzone for uploads
v2026-10-21.3
New: Dropzone, a dashed area that takes dropped files or opens the file
picker on a click, Enter or Space. It checks each file against accept,
maxSize and maxFiles, hands back the ones that pass (onFiles) and the
ones turned away with why (onReject), and never uploads: the app does. Under
it, DropzoneFiles and DropzoneFile list what the app holds, each with its
picture or a file icon, its name and size, a spinner while it uploads, why it
failed, and a remove button. Apps were hand-building this around a hidden file
input. AddTile stays for the small "add one" square at the end of a row of
thumbnails.
Confetti can burst up out of an element
v2026-10-21.2
burst(element) throws the flakes up out of that element (the button just
clicked) in a fan, before they fall back and land. Two new settings tune it:
--confetti-rise (how high, default 320) and --confetti-spread (the fan's
half angle in degrees, default 30). Thrown flakes lose their sideways speed to
air drag. A --confetti-hold of Infinity now keeps the flakes and stops the
animation once they settle, instead of repainting a still picture.
A rule for dates and times
v2026-10-21.1
docs/ui-md-writing.md gains "Dates and times": numerals, short month first
("Oct 21"), the year only when it is not this year or the date is a record,
ranges with "to", "3:05 PM" with its zone, the
weekday spelled out, and a word in front saying what the date means
("Expected Oct 21 to 22"). It also asks for a fixed locale and time zone, since
the viewer's own prints a different format per reader and can disagree between
server and browser.
Footer column titles bind to their links
v2026-10-20.5
A SiteFooterColumn title now sits 8px above its first link's text, the
label rung, down from 14px. At 14px the title was about one link pitch away
and read as another row instead of the heading of its list. The space between
links is unchanged: it is the links' tap area.
Moves on screen
In SiteFooterColumn: every link under a title moves up 6px.
Code under a lead-in gets more air
v2026-10-20.4
A paragraph directly above a code block or table is now 24px from it, up from
16px. --rhythm-figure-bind is its own value (1.5rem) instead of reading
--rhythm-base: at 16px the ringed box still sat tight on its lead-in. The gap
after a figure stays 32px, so the code still binds to the line above it.
Moves on screen
In Prose: a figure under a paragraph, and everything after it, moves down 8px.
Prose figure gaps retuned
v2026-10-20.3
Prose spaces code blocks and tables by what sits beside them. A paragraph
directly above a figure is now 16px from it (--rhythm-figure-bind reads
--rhythm-base), up from 8px in the last release: 8px crammed the code against
its lead-in. The gap after a figure is a new token, --rhythm-figure-after
(32px), down from 48px, so a break inside one step no longer reads like a new
section. A heading directly above a figure keeps its own below gap (40px under
an h1, 16px under the rest) so it still breathes more above than below. A list
or anything else above a figure keeps --rhythm-figure (48px).
Moves on screen
In Prose: a figure under a paragraph moves down 8px, everything after a
figure moves up 16px, and a figure right under a heading moves up (32px under
h2 to h6, 8px under an h1).
A display size for PageHeader; a lead-in paragraph binds to its code block
v2026-10-20.2
PageHeader takes size="display", for an article's or essay's masthead: the
title at text-display over a text-lg summary. The gap between them grows
with the title, 8px plus a quarter of its size (17px under a 36px title), since
a flat 12px reads cramped at that size. It reads a new rung,
--spacing-rhythm-trim-pair-display. The gap to children is the new
--spacing-rhythm-heading-display (40px), also on Stack as heading-display.
A new titleClassName prop takes a project's own headline face; it styles the
face, never the size. If you hand-built a display masthead on trim-pair,
replace it with <PageHeader size="display" titleClassName="...">.
In Prose, a paragraph directly above a pre or table now sits 8px from it
instead of 48px, on the new --rhythm-figure-bind token, which reads the
bind rung. That paragraph introduces the figure, so it binds to it. The gap
below a figure stays 48px.
Moves on screen
In Prose, every code block or table that follows a paragraph moves up 40px.
New build failures
None. trim-rung-mismatch also reads --spacing-rhythm-trim-pair-display now,
and flags it in a file that trims nothing, as a warning like the rest of that
rule.
Brand marks: the real provider logos
v2026-10-20.1
New part, brand-marks: GoogleMark, GitHubMark, MicrosoftMark and
AppleMark, the real logos for a sign-in or "continue with" button. They used
to live privately inside AuthForm, so every site drew its own Google G and no
two matched. Import them instead: iconStart={<GoogleMark />} on a Button.
Google and Microsoft keep their brand colors; GitHub and Apple follow the text
color. There is no one-color Google mark, on purpose: Google's sign-in rules
require the four-color G, so on a colored button the fix is a neutral button.
kit installs it, and AuthForm now imports its marks from it.
Carousel arrows: one style
v2026-10-19.5
Carousel arrows have one style now, the round chip with a ring edge. The plain
chevron is gone: at half opacity it went hard to read whenever an arrow sat on
a picture, and on a phone it always did. variant on the arrows and on
CarouselControls is still accepted so nothing fails to compile, but it does
nothing. tone still picks the fill.
Moves on screen
- Every carousel arrow is now a 28px round button with a 16px chevron, a card fill and a ring edge, at full opacity. Arrows that were already chips shrink from 32px with a 20px chevron. Each chevron sits 1px toward its point, which centres it by eye.
CarouselControlsno longer pulls itself 12px past the end of its row. That pull lined up a plain chevron's ink with the edge; a chip's edge is its shape, so it sits flush.
New build failures
carousel-arrow-variant-removed(warning): names avariant="bare"orvariant="chip"left onCarouselPrevious,CarouselNextorCarouselControls. Delete it.
Lockup body; spacing and line-number fixes
v2026-10-19.4
LockupBody holds content that belongs to a lockup's message, between the
description and the actions: an install command, an email field. It takes the
same space above it as the action row and follows the lockup's alignment.
Moves on screen
- A
SearchTriggerwith ashortcutmoves its keycap 7px toward the end, so it sits as far from the end as from the top and bottom. - A card holding only a
CardHeadergains 20px under the header, the same as above it. Its text used to sit on the card's bottom edge. If you addedpb-5to work around it, nothing moves and you can delete it. CodeBlock showLineNumbersnow numbers its lines. Every line read 0 before, because the counter classes compiled to nothing.- A
Lockupno longer grows past its container when one line in it cannot wrap (an install command). The page stops scrolling sideways on a phone and that line scrolls inside its own box.
Header icon button, search trigger, carousel dots
v2026-10-19.3
Three new parts for site chrome and carousels. HeaderIconButton is the bare
icon control for a header (a GitHub link, a menu toggle), and ThemeToggle is
now drawn on it, so the two match. SearchTrigger is the field-shaped button
that opens search, with its shortcut in a keycap. CarouselDots draws one dot
per slide position inside a Carousel. CopyButton gains reveal="always"
for a box with no group/code parent, and onCopy, called after a copy
succeeds. PopoverContent gains flush, which drops its inset for a list
that pads its own rows, in place of className="p-0".
A large Lockup in a Section stops padding itself
v2026-10-19.2
Lockup size="lg" added 64px above and below itself and 16px at each side.
That suits a Lockup alone on an empty page, but inside a Section the band
already spaces it, so a hero got both and every one needed px-0 py-0! to undo
it. A large Lockup inside a Section now adds no space of its own. Anywhere
else it pads as before, and a px-* or py-* you pass still wins.
Moves on screen
- A
Lockup size="lg"inside aSectionloses 64px above and below and 16px at each side. If you hadpx-0 py-0!on it, nothing moves; you can delete that now.
Kit-look no longer ships the page review
v2026-10-19.1
kit-look no longer ships ui-md-review.mjs, the script that asked a judgment
model whether a page rebuilds a kit part or skips its empty or error state. It
needed its own paid key and was never part of the kit's checks. A pull does not
delete a copy you already have; remove scripts/ui-md-review.mjs yourself if
you do not use it. ui-md-look.mjs is unchanged.
The page scrolls over a vertical ScrollFade
v2026-10-18.1
The mouse wheel stopped moving the page while the cursor was over a vertical
ScrollFade with a max height, such as WeekSchedule's hour grid. Its
overscroll-none kept the scroll from ever reaching the page. The region now
scrolls itself first, then hands the scroll to the page at its top or bottom.
Sideways still does not bounce or chain. The 2026-10-09 fix did the same for
horizontal strips.
Moves on screen
- The page scrolls when the wheel reaches the end of a vertical
ScrollFade. - In Safari, a vertical region may bounce a little at its ends.
Combobox trigger is a combobox to assistive tech
v2026-10-17.1
The Combobox trigger was a plain button carrying aria-invalid, which a
button role does not support, so jsx-a11y/role-supports-aria-props warned in
every app that used it. The trigger now has role="combobox" with
aria-expanded and an aria-controls that points at its popup, which is the
select-only combobox pattern and makes aria-invalid valid. The popup keeps
Base UI's aria-haspopup="dialog". Props and styling are unchanged, and the
aria-invalid: border and ring still apply.
CardMedia, a still Skeleton, a MediaCard description, and a blog recipe
v2026-10-16.17
New part, CardMedia: an image inside a card's edge, above its header, with a
hairline under it. aspect takes landscape (default), wide or square. Use
it for a linked card led by a picture where the text sits on the card, not over
a faded image.
Skeleton takes still, which drops the pulse, for a block that is a drawing
rather than a loading state. MediaCard takes description, a line bound to
the title at the pair rung. Copy belongs there, not in meta, which sits a
cluster rung away.
New recipe, copperleaf: a blog index (a lead post and a grid of posts) and an
article page.
CardLinkRow, and the dashboard recipe is now a store admin
v2026-10-16.16
New part, CardLinkRow: a row in a card that links somewhere, with an optional
line under the label and a count of what is waiting there. It is the "needs
attention" list on a home screen (4 orders to fulfill, 3 refund requests), which
the kit had no part for. kit installs it.
The dashboard recipe that get_recipe("dashboard") serves was rebuilt as a
store admin with three pages in one AppShell. Home holds the numbers for one
date range, a setup checklist, what needs attention and the newest orders.
Orders holds the list tools: status tabs, search, a date range, more filters,
paging and an order panel. Calendar holds the week. The old single page carried
two tab strips and a toolbar of filters on a home screen, which is a pattern
worth not copying.
AppShell: an app page's frame that works on a phone
v2026-10-16.15
New part, AppShell: the sidebar of destinations, the top bar and the page,
in one frame. From md up the sidebar sits on the left at the screen's full
height; below it the sidebar hides and a menu button in the top bar opens the
same Nav in a drawer, which closes when a link is followed. Before this the kit
had no answer for an app sidebar on a phone, so a hand-built one stayed 224px
wide on a 390px screen and pushed the page sideways. kit installs it.
A scrolling Nav shows the current page
v2026-10-16.14
A horizontal Nav with more links than fit (a FloatingBar on a phone, a narrow header) scrolls sideways. It used to open scrolled to the start, so the current page could sit out of sight. It now scrolls itself so the current item is centred, once when it opens and again when the page changes. Only the Nav scrolls, never the page, and it leaves the row alone if the current item is already in view or you have scrolled it yourself.
Moves on screen
A horizontal Nav that overflows opens scrolled to its current item instead of to its first.
Pointer cursor on everything clickable
v2026-10-16.13
Anything you can click now shows the hand cursor: links, buttons, tabs, menu
items, options, radios, checkboxes, switches, selects and summaries. Tailwind v4
leaves buttons on the arrow, and only some kit parts set the hand themselves, so
Tabs and Nav items showed the arrow or a text cursor. One rule in the token
file's base layer now covers them all. It has no specificity, so a component's
own cursor (a disabled control's not-allowed) still wins, and it skips
anything disabled.
Moves on screen
The cursor over Tabs, Nav items, radios, switches, menu items and options is the hand. Nothing moves.
A floating bottom bar
v2026-10-16.12
New: FloatingBar, a bar that floats at the bottom of the screen (or the top,
with side="top") over a band that blurs what scrolls under it. Put a Nav
or a few actions inside. Its inset is the chrome gutter plus the phone's safe
area, and its height and corner follow size, which you match to the
controls inside. Pages under it put floatingBarReserve({ side, size }) on
their outer element so their content is not stuck behind it. It comes with kit, or install it alone:
npx shadcn@latest add https://ui.mattdowney.com/r/floating-bar.json.
A quote part
v2026-10-16.11
New: Quote, a quote with who said it, built from QuoteText and
QuoteAuthor (name, role, an optional avatar and company logo). plain
stands on any surface, variant="card" is one testimonial in a row of cards
with the names lined up, and size="lg" is a featured pull quote. mark adds
a quiet quote mark. It comes with kit, or install it alone:
npx shadcn@latest add https://ui.mattdowney.com/r/quote.json.
A full-screen sign-in layout
v2026-10-16.10
New: AuthLayout, the whole sign-in screen. The brand sits at the top left,
the form is centred in a narrow column on the page, and an optional panel
beside it sells the product: a crop of the product, a quote, a row of logos.
The panel is inset with rounded corners on a neutral fill, and hides below
lg, where the form takes the screen. Without a panel the form centres on the
whole page. It comes with kit, or install it alone:
npx shadcn@latest add https://ui.mattdowney.com/r/auth-layout.json.
AuthForm takes surface="bare" to drop its Card and stand on the page, as
it does inside AuthLayout. card stays the default.
A sign-in form for a page of its own
v2026-10-16.9
New: AuthForm, the sign-in form standing on a page instead of in a dialog.
It sits in a Card, its title is the page's h1, and it adds two things the
dialog lacked: forgotPassword, a link beside the Password label, and
error, for a failure your auth client returned instead of threw. It comes
with kit, or install it alone:
npx shadcn@latest add https://ui.mattdowney.com/r/auth-form.json.
AuthDialog now renders AuthForm inside its dialog, so the two cannot
drift apart, and it takes the same two new props.
Moves on screen
- An
AuthDialogthat showed an error and was closed now opens clean the next time, instead of still showing the old message.
Tables written in a server page get their labels and widths
v2026-10-16.8
Fixed: a Table written in a Server Component (an App Router page, by
default) lost two things in silence. With stack, the phone layout showed
values with no column names beside them. With layout="fixed", the declared
column widths were ignored. Both now work from server and client pages alike,
and the labels are in the first HTML, so a phone never sees them pop in.
Toast had the same bug finding its ToastPanel, and is fixed too.
Moves on screen
- A
stacktable in a server page now shows each value's column name on a phone. - A
layout="fixed"table in a server page now honours each column'swidth, and itsfirstColumnWidth/lastColumnWidthdefaults.
A dashed add tile
v2026-10-16.7
New: AddTile, the dashed "add one" tile at the end of a row or grid of
thumbnails. It is a plain button, so it can open a file picker or trigger a
menu. On hover, focus and while a file is dragged over it (dragOver), the
dashed edge, the icon and the label turn accent with no fill. Apps were
reshaping a ghost Button for this, and its hover painted an accent wash.
MCP made private
v2026-10-16.6
The UI.MD MCP server now answers only calls that carry its owner's key, so
installs no longer connect it. An upgrade removes the ui-md entry an earlier
pull added to your .mcp.json and leaves every other server alone. Nothing
else changes: uimd-build and the AGENTS.md block use the rules file when
the MCP is not connected, which they already did.
Look check
v2026-10-16.5
A new optional item, kit-look, checks a running page the way a person sees
it. It renders the page at phone and desktop width, in light and dark, saves a
screenshot of each, and reports a page off the kit background, stacked blocks
a few pixels out of line, sideways scroll, and clipped text. Install it with
npx shadcn@latest add https://ui.mattdowney.com/r/kit-look.json and run
node scripts/ui-md-look.mjs <url>. It uses the Chrome on your machine, through
playwright-core. The same item ships ui-md-review.mjs, which asks a judgment
model whether a file hand-builds a kit part, or shows a list with no empty state
or a form with no error state. It runs only on your own TypeSafe key and skips
without one. The uimd-build skill runs both before calling a page done when
they are installed.
Moves on screen
The default SiteFooter strip's links lose their 8px inset, so they line up
with the fine print when the row wraps on a phone. On a wide screen the gap
between the fine print and the first link closes from 32px to the row's 24px.
Page background, section heading edge
v2026-10-16.4
Two fixes from pages agents built with the kit.
Moves on screen
- The page takes the kit's background and text colour. The token file now sets
background-colorandcoloronbody, so a project that never addedbg-background text-foregroundto its ownbodystops rendering on plain white. A project that already sets them sees nothing change. Lockupatsize="section"andsize="page"loses its 16px side inset, so a section heading lines up with the cards, list or FAQ under it instead of sitting 16px in from them. AclassName="px-0"you added to fix that is no longer needed. The empty-state sizes (sm,md,lg) keep the inset.
MCP wired into installs
v2026-10-16.3
The kit now connects its MCP server, https://ui.mattdowney.com/mcp, which
tells an agent which part, recipe and rung fits the screen it is building. An
upgrade run by docs/ui-md-upgrade.md adds one ui-md entry to your
.mcp.json (every other server in the file is left alone) and one line about
it to the AGENTS.md block. Claude Code asks once before it trusts the server;
approve ui-md. The uimd-build skill now asks the MCP first and falls back
to docs/ui-md-rules.md when it is not connected.
Save check, one-file checking, section padding rule
v2026-10-16.2
verify-ui takes file paths: node scripts/verify-ui.mjs app/pricing/page.tsx
reports on just those files, offline, in about a second. A new optional item,
kit-hooks, uses it as a Claude Code hook: each component file an agent saves
is checked and the findings go straight back to the agent. Install it with
npx shadcn@latest add https://ui.mattdowney.com/r/kit-hooks.json, then
node scripts/ui-md-save-check.mjs --install, which adds one entry to
.claude/settings.json and leaves the rest of the file alone.
New build failures
raw-section-frozen-padding lands as a warning: a plain <section> padded
with a numbered vertical class of 32px or more (pb-16, pt-12). Swap it for
<Section>, whose pad and padTop read the rhythm rungs, or read one rung
on one side (pb-(--spacing-rhythm-section)). --strict fails on it now.
Week schedule event menu
v2026-10-16.1
WeekSchedule takes eventMenu: a function that returns menu items for an
event, opened by right-click, a long press on touch, or the keyboard's menu key.
It is also the touch path for moving an event, since touch does not drag.
week-schedule now installs dropdown-menu.
Video controls end padding
v2026-10-15.10
Video controls with no speaker (showSound={false}) get 12px of padding on the
right, so the total time sits as far from the edge as the play icon does on the
left. It sat 4px from the edge before.
Moves on screen
The total time on a soundless player moves 8px in from the pill's right edge.
Video controls
v2026-10-15.9
New part, video-controls, in kit: a floating pill of controls over a
video or animation player, with play, the elapsed time, a seek bar, the total
and an optional speaker. It shows when the pointer moves, while paused, while
seeking and while a control has focus, and hides after two idle seconds. A
mouse click on the video plays or pauses; on touch the first tap only shows
the pill. White on a dark scrim in both themes.
Pass a <video> ref as media, or an adapter for any other player (play,
pause, seek, currentTime, duration, paused, muted, setMuted,
subscribe). hotkeys turns on Space and M for a player that owns the page.
A column width for short list pages
v2026-10-15.8
A new container width, --container-column (36rem, 576px), so max-w-column
exists. It is for a page that is a short list under a title, one row per item:
max-w-lg (512px) reads too narrow for those rows and max-w-xl (672px) too
wide. Prose still caps at max-w-measure; a page's main content column is
still max-w-xl.
Lockup title and eyebrow read tokens you can retune
v2026-10-15.7
LockupTitle now reads its size and weight from tokens, one pair per Lockup
size: --lockup-title-sm, -md, -section, -page and -lg, each with a
-weight, and lg adds --lockup-title-lg-leading and -tracking.
LockupEyebrow reads --eyebrow-case, --eyebrow-tracking and
--eyebrow-weight. Set them in your own :root to match your type ramp
(a smaller hero, a display weight, an eyebrow in sentence case), instead of
forcing the classes with !. The defaults are the values these used before.
A centred PageHeader
v2026-10-15.6
PageHeader gains align="center" for a masthead that opens a page you
browse, a blog or a shop, with no action. It centres the title and
description and caps the description at the reading measure. A centred
heading with a call to action is still Lockup, so align="center" does not
accept action.
CardFan: photo cards and a fan inside one link
v2026-10-15.5
CardFan gains two props. variant="image" is for cards that are all
picture: the card drops its outside ring for a hairline inside its edge
(pure black or white at 10%) and keeps its shadow, so a photo carries one line,
not two. decorative is for a fan inside a larger link: mark the link
data-card-fan-trigger, and hovering anywhere on it, or tabbing to it, fans
the covers. The covers take no pointer, focus or name. If you wrote your own
trigger rule for this, delete it.
Moves on screen
With reduced motion turned on, the fan now opens and closes without moving between poses. Before, the open rule outranked the reduced-motion one, so the cards still slid.
A trailing value on a Select row
v2026-10-15.4
SelectItem gains trailing: a short value, such as a count, on the row's
right edge. On a row that has it the check moves to the left edge to free that
side, as macOS menus do. The value is muted, one size below the label, uses
tabular numbers and brightens with the highlighted row. It renders outside the
label, so the trigger and typeahead show only the label.
A version, a public changelog, and an update check
v2026-10-15.3
The kit now has a version: the newest changelog entry's date and its count
for that day (2026-10-15.3 is the third entry on the 15th). It is written at
the top of this file, so the version you are on is the one in your installed
copy. Nothing to keep in step by hand: it is read off the entries.
node scripts/verify-ui.mjs --updates compares your version with the live
changelog and lists every newer entry with its two cost sections, before you
pull. It needs network access, changes no file, and runs no style checks. An
install from before this entry has no version line; the flag matches your
newest heading instead.
Every entry is also public at https://ui.mattdowney.com/docs/changelog.
Moving events on a week schedule
v2026-10-15.2
WeekSchedule gains onEventDrop(dragged, target), and events gain
draggable and droppable. Drag a draggable event onto a droppable one with
the mouse, or focus it, press Space, choose a spot with the arrow keys and
press Space or Enter (Escape cancels). Each step is read out to screen
readers. The component moves nothing itself: update events in the callback.
Touch does not drag, so keep another way to move an event on touch.
A soft destructive button
v2026-10-15.1
Button gains variant="soft-destructive": soft's tinted fill in the
destructive hue, for the one delete in a row of soft peers. A ghost delete
beside soft buttons read as bare red text where its neighbours carried a fill.
It holds its hover fill while loading and takes the solid destructive count
chip, as ghost-destructive does.
Footer year, and a starter page
v2026-10-14.2
SiteFooter's copyright year now comes from a small client part,
site-footer-year.ts, installed with site-footer. Reading the date during
the prerender failed next build in any Next.js app with cacheComponents on,
which a fresh create-next-app now turns on by default.
New starter item: a placeholder home page (site header, the project name
and one line centred on the page, footer) for a brand new project. It installs to
app/page.tsx, so only add it to an empty app.
Moves on screen
- The footer's year is filled in by the browser just after the page loads, so the server HTML ends at "©". It sits at the end of the line, so nothing beside it moves.
Icon beside text
v2026-10-14.1
New rule and token for an icon you place beside text outside a kit component:
it takes the box Button already pairs with that text size (11px text, 12px
icon; 12-13px, 14px; 14px, 16px). The common case ships as --spacing-icon
(14px) with its own utility, size-icon. See "An icon you place beside text"
in the rules.
No default rule ships with it, because a CSS width beats Tabler's size prop
and would shrink every icon sized that way. If you already set a default icon
size in your own CSS, point it at var(--spacing-icon) and drop your own copy
of the value. The rules show the one-line opt-in.
TextareaGroup
v2026-10-13.3
New TextareaGroup, TextareaGroupControl and TextareaGroupFooter in
textarea: a text field with its own actions or a character count in a footer
row, under a full-bleed rule, inside the field's edge. The group draws the field
and its states, so the control inside renders bare. If you pinned buttons or a
count over a Textarea by hand, this is the part for it.
SkeletonText
v2026-10-13.2
New SkeletonText beside Skeleton: <SkeletonText lines={2} /> holds the
place of lines of text. Each line is one line of the surrounding type, so it is
exactly as tall as the text that replaces it. Set the type on it or its parent
the way the real text sets it. If you built text placeholders from stacked
Skeleton bars with a gap, switch: those never match the text's height.
Cancel on the left
v2026-10-13.1
The Cancel button in a dialog, sheet and alert dialog footer now sits at the far left of the row, apart from the actions, so the way out never reads as one of the choices. On a phone the footer still stacks, Cancel at the bottom.
Moves on screen
DialogFooter showCancelButtonandSheetFooter showCancelButton: Cancel moves from beside the actions to the left edge fromsmup.AlertDialogCancel: the same, when it is the first child ofAlertDialogFooter. Placed after the action, it now pushes the action left instead, so put Cancel first.
Palette edges and key hints
v2026-10-12.5
New KbdHint and KbdGroup for a palette footer:
<KbdHint keys={["↑", "↓"]}>Navigate</KbdHint> shows one cap per key, 4px
apart, and puts 6px between the keys and the word. That 6px is Button's
icon-to-label gap at the same text size. If you wrote a keycap and a word with
a space between them, switch to KbdHint: a space is a different width in
every font.
Moves on screen
- In
CommandDialog, the search field and the footer are inset 16px on both sides, the same as the rows' content. They were 12px. The search icon is 20px (was 16px), so it lines up with row icons andCommandItemMedia. The footer's last hint now ends whereCommandItemMetaends.
Palette rows: media, meta, keycaps in buttons
v2026-10-12.4
Two new parts for a result row. CommandItemMedia is a 20px square image at
the start of a row (pass src, or your own image as children), with a muted
fill and a hairline while it loads; alt is empty by default. CommandItemMeta
is words at the end of a row, such as a topic: muted, one size below the label,
with normal letter spacing. Use CommandShortcut only for key glyphs. If you
built a thumbnail span or a muted end-of-row span by hand, switch to these.
The rules file now says how both are sized. Nested radius has a floor: when
the parent's radius minus its padding is 0 or less, use rounded-xs. Media
inside a padded row keeps the row's 8px gap rather than the 16px media rung,
and is no taller than the label's line.
Moves on screen
- In Command, a highlighted row's
CommandShortcutturns foreground along with its label. It was muted. Kbdinside a Button: the keycap's edge now follows the button's label colour instead of a neutral grey ring, and its text is 80% of the label (was 70%). Outside a Button nothing changes.
Command palette: top position, footer, keycaps, grey highlight
v2026-10-12.3
What a search palette needs, taken from a real one. CommandDialog takes
position="top", which pins the window 12% down the screen and lets it grow
downward only, so the input no longer jumps when the result count changes. It
takes a footer for key hints (hidden on touch, which has no keys) and
commandProps, which reaches the inner Command: pass
commandProps={{ shouldFilter: false }} when a server supplies the results.
CommandList takes height="tall" (60% of the screen instead of 300px), and
is tall by default inside a top-positioned dialog. New Kbd is a keycap for
those hints and for a shortcut inside a button label; inside a Button it takes
the label's colour. If you hand-built any of these (a Dialog + Command
shell to pass shouldFilter, a top offset on DialogContent, a plain <kbd>),
switch to the parts.
Moves on screen
- The highlighted row in DropdownMenu, Select, Command and Combobox is a soft
neutral grey (
bg-secondary) under foreground text, in both modes. It was the accent tint with accent text. Destructive menu rows are unchanged. - Every overlay backdrop (Dialog, AlertDialog, Sheet, Drawer) now fades on the
motion roles: 200ms in and 150ms out, as before, but a retune of the
largeandsmallroles now reaches it too.
Metric card tiles
v2026-10-12.2
MetricCard takes layout="tile" for a row of KPIs that are each one number,
a status and one line. It drops the header band and its divider: the label sits
on top in small muted type, the badge moves to the top-right corner, then the
value and the goal line. A badge in the corner does not change the row's
height, so tiles with and without one line up. trend, delta, unit and
children work as before. Keep the default layout for a metric with a chart or
more than one line under it.
Chart
v2026-10-12.1
New Chart: one series over time that you read, where Sparkline is one you
glance at. It shows a labelled scale with a faint line at each step, labelled
periods, an optional dashed target line named in a key above the plot, and each period's
value on hover. A null value is a gap, never a zero. Bars measure from zero
and lines from the range the data covers. Marks take the text colour
(text-accent by default), so one class on the root recolours a series. The plot is at least 192px tall
and the labels sit outside it; a height on the root grows the plot.
Cards in a carousel keep their edges
v2026-10-11.4
CarouselContent takes ringRoom. Turn it on when the slides are Cards: the
carousel used to clip each card's ring and shadow at the slide's edge, so the
first and last cards lost their side edges. It gives 12px of room on every
side without moving a slide or changing a snap. If you made room by hand
(padding on the track, a negative margin on the viewport, a px-px wrapper in
each slide), remove it and use ringRoom instead.
Media card
v2026-10-11.3
New MediaCard: a linked card led by its image. The image dissolves into the
page at its foot and the title sits over the fade, with an optional meta line
(a Badge) under it. Props set the image shape, how much of it fades, the
title size, and whether the title and meta pin to the card's foot so meta
lines up across a row.
Carousel arrows in a title row
v2026-10-11.2
Carousel arrows take placement="inline" to sit in normal flow, and a new
CarouselControls part renders both of them in a row, 8px apart, for a
heading's action slot. No more overriding the arrows' positioning with
className. If you did (static translate-y-0 sm:left-auto), swap it for
<CarouselControls />. Inline arrows stay bare chevrons on a phone, where
floating ones switch to a filled chip.
Page header action no longer pushes content down
v2026-10-11.1
A PageHeader action used to set the height of the title row, so a 32px
button or a pair of carousel arrows beside a short title pushed the content
below about 18px further down than in a section without one. Beside the
title, the action now sits centred on the title's first line and adds no
height. When the row is too narrow and it wraps under the title, it takes its
own space as before.
Moves on screen
- Every
PageHeaderwith anactionandchildren: the content moves up to the same distance below the title as a header without an action. - The action itself moves from the title's cap top to centred on the title's first line.
Card fan is a gallery deck
v2026-10-09.17
The card fan now behaves one way: a calm gallery deck. The card you point at comes forward, turns most of the way level and rises; the others darken. It runs on the large motion role with no overshoot, where it used to spring open. The default fan is three portrait cards fanned sideways in a 180px box.
Moves on screen
- Every card fan: no bounce on hover, the hovered card straightens and lifts, the others darken.
- A fan using the default size or default cards is now 180px, with 96 by 128px cards fanned sideways, where it was 120px with 78px squares fanned upward. Pass your own slots and size settings to keep the old shape.
Card fan: settings for the card under the pointer
v2026-10-09.16
Three new card fan settings, all off by default. --card-fan-focus-straighten
(0 to 1) turns the hovered card back toward level, --card-fan-focus-lift
raises it, and --card-fan-dim (0 to 1) darkens the other cards while one is
hovered. With the existing ease and duration settings, a fan can now behave
like a calm gallery deck instead of a springy stack.
A hovered card fan card comes to the front
v2026-10-09.15
Each card's stacking order was set inline, which outranks the stylesheet, so the rule that lifts a hovered or focused card to the front never applied: the card grew, but stayed behind the cards stacked over it. The order now rides a variable the stylesheet reads, so hover and focus win.
Moves on screen
- Hovering or focusing a card in a card fan brings it in front of the others.
Card fan cards can be links, and resize
v2026-10-09.14
A card fan's card can now be a link: give its slot a render, such as
<Link href="/posts/one" />, and it opens that page. Without one it stays a
button. The fan also resizes: --card-fan-size sets the box, and
--card-fan-card-width and --card-fan-card-height set each card, so a fan
of portrait covers no longer has to be a 78px square. The defaults are the old
sizes.
Moves on screen
- A card clips its content to its own corners, so an image inside one is rounded. A card with no content looks the same as before.
- A focused card shows a focus ring and comes to the front.
Confetti lands on more than one thing
v2026-10-09.13
useConfetti takes a list of elements as its third argument, as well as
one. Flakes land on the first one they fall onto. An element with a box of
its own (a button, a card, an image) catches them on its top edge, rounded
only as much as its own corners are. Text catches them on the letters: each
letter's top outline, so flakes rest on the shapes and drop through the gaps
between them.
Moves on screen
- A square-cornered target catches flakes on a flat top. It used to be read as a pill whatever its corners, so a tall image caught flakes inside itself. A fully rounded button lands them exactly as before.
- A heading or text target catches flakes on its letters, not on the empty top of its box.
A tab strip that scrolls moves as one piece
v2026-10-09.12
When an overflowing tab strip brought the new tab into view, it scrolled at the browser's smooth-scroll speed while the pill slid on its own, faster spring. The two fought: the labels slid under a pill that barely moved, and the strip kept drifting after the pill had landed. The strip now scrolls on the pill's spring, so pill, labels and strip land together.
Moves on screen
- Switching tabs on a strip wider than its space: the strip and the pill now move as one and stop at the same moment. A wheel or touch on the strip during the glide stops it. A strip that fits is unchanged.
Lockup side by side, placed
v2026-10-09.11
Lockup takes align="end", and layout="side" now reads align: it places
the media and copy together at the start, centre or end of the lockup's width.
Beside the media the copy stays left-aligned; once it stacks, it follows
align. A side lockup with no align still starts. In the stack, end
right-aligns the text and the action row.
Lockup side by side
v2026-10-09.10
Lockup takes layout="side": the media in its own column, and the eyebrow,
title, description and actions together in a column beside it. Built for a
success screen with art beside the message. sideAlign="center" centres the
copy on the media; the default lines it up with the top. Under 24rem of
lockup width it stacks, media on top and copy under it, left-aligned. The
stack stays the default.
Tabs with more tabs than fit
v2026-10-09.9
A tab strip wider than its space now scrolls cleanly, fades its edges, and keeps the active tab in view.
Moves on screen
- The first tabs of an overflowing strip are no longer cut off past the left edge where no scroll could reach them.
- An overflowing strip fades at each edge that has more tabs past it: the right edge at rest, both once you scroll, the left alone at the end. A strip that fits shows no fade.
- When the active tab changes, the strip scrolls it toward the middle.
TabsListnow renders a wrapper (data-slot="tabs-list-track") that holds the border, fill and corners, with the scrolling list inside it. YourclassNameonTabsListgoes on the wrapper. If you styled[data-slot="tabs-list"]directly for its border or fill, move that to the new slot.
PageTitleRow fits narrow rows
v2026-10-09.8
PageTitleRow now reacts to its own width instead of the screen's, so a row
in a narrow pane behaves like a row on a phone.
Moves on screen
- A search field and a button in
actionno longer run past the row's edge when space is short. Under 42rem of row width the actions take the room the title leaves, and a search field grows to fill it beside the button. When they do not fit beside the title, they drop to the next line together. - With
center(a tab set between title and actions), the row switches to three columns at 32rem of row width rather than at thesmscreen width. Below that, the title and actions share the first line and the tabs take the next. Above it, the outer columns split what the tabs leave, so the tabs stay centered and no longer overlap a long title.
The gap under a page title row
v2026-10-09.7
The app page rule put PageTitleRow and the page body in one
<Stack gap="region">, which left about 70px between the title and the
first thing under it. The gap now depends on what the row titles. Over one
surface, a single table or list, it is cluster. Over a group of blocks, a
metric grid or several sections, it is the heading step: heading-lg under a
page row, heading-md under a section row. The body keeps region between
its own blocks. "Page composition" in the rules shows it.
A pinned table column keeps its row lines
v2026-10-09.6
On a narrow screen, a Table that scrolls sideways pins its first column.
That column's fill could cover the line between rows, so on some tables the
row lines stopped short of the first column. They now run the full width.
Moves on screen
- Row lines under a pinned first column now show on every table.
The page scrolls over a table
v2026-10-09.5
The mouse wheel did nothing while the cursor was over a table, because the table's sideways scroller swallowed up and down scrolling. It now passes those through to the page. Sideways scrolling still does not bounce or chain. A vertical ScrollFade is unchanged.
Moves on screen
- The page now scrolls when the wheel is over a table or a horizontal ScrollFade.
`PageTitleRow` gets a section size
v2026-10-09.4
PageTitleRow takes size="section" for a titled group inside an app page,
such as a review queue's "Pending" and "Done", each over its own table. It is
an h2 at text-base in a 32px row, one step under the page title's
text-lg in a 40px row, with the same slots: the count goes in titleBadge
and never changes the row's height. "Page composition" in the rules says how
to lay it out: a lone search beside the action, a set of filters in a
TableToolbar under the row, cluster from the row to its table, peer
from one section to the next, and no Card.
Button loses the `outline` variant
v2026-10-09.3
Button no longer has variant="outline". It was an accent border on a
transparent fill, and it sat next to soft and subtle as a third way to
say "not the primary". Replace every outline Button with soft. There
is no alias and no grace period. Badge keeps its own outline variant; that is a different
component and did not change.
Moves on screen
WeekSchedule's Today, previous week and next week buttons change from outline to subtle: a neutral tinted fill instead of an accent border.- The Button docs page, the examples and the pricing rule in the rules file
now show
softandsubtlewhere they showedoutline.
New build failures
variant="outline"onButtonno longer exists, so TypeScript fails on it, and the same goes forbuttonVariant="outline"on aCalendaror any component of yours that forwards the variant toButton. Replace it withsoft. Runnode scripts/verify-ui.mjsto list every call site.- New warning
button-outline-removed: names eachoutlineButton,buttonVariants({ variant: "outline" })and CalendarbuttonVariant, including the ones TypeScript cannot see. Badge'soutlineis not flagged.
Expressive motion, and nine recipes
v2026-10-09.2
New: a third motion role for moments, the rare times something finishes,
arrives or gets celebrated. Five tokens: --ease-expressive,
--ease-expressive-spring, --ease-expressive-pop,
--transition-duration-expressive-in (350ms) and
--transition-duration-expressive-out (250ms), as the classes
ease-expressive* and duration-expressive-*. "Expressive motion" in the
rules says when a moment earns them, and no kit component uses them.
Also new: nine finished recipes that read those tokens, as optional items
outside kit: spinner-to-check, dot-field-reveal, image-zoom,
dissolve, confetti, card-fan, fan-menu, shimmer-surface and
gradient-text. Install one by name, or all nine with kit-motion.json. Each
lands in components/motion/ with its own stylesheet.
`brand`, a second colour family
v2026-10-09.1
New: a brand colour family for expressive moments: gradient text, an upgrade
button's glow, a premium badge, illustration, confetti. It is built like
accent, with eleven steps (bg-brand-50 to bg-brand-950) at the same
lightness and chroma per step, on its own hue, --color-brand-h (306.54, a
violet). --brand is the 500 step in both modes, and --brand-foreground
picks a black or white label the way --accent-foreground does. Accent stays
the action colour. "Two colours: accent acts, brand expresses" in the rules
says which goes where. If you already declare your own --brand in your CSS,
yours still wins, since it sits below the kit import.
New build failures
- None. New warning
brand-on-control: abrandcolour in theclassNameof a kit control (Button, Input, Textarea, Select and Combobox triggers, Checkbox, Switch, Radio, Slider, Tabs, Nav items, Segmented control). Ashadow-brand-*glow is not flagged. Escape hatch:ui-md-allow-brand-control.
A gap log, and the screen before the parts
v2026-10-08.1
When the kit has no part, variant or rule for what a screen needs, your agent
now writes it down in docs/design-gaps.md, along with every ui-md-allow-*
or motion-exempt comment it adds. The kit never ships or overwrites that
file. Read it now and then: it is the list of what the kit should add next,
and sending it upstream is how it gets added. "When the rules do not cover it"
in the rules gives the entry format.
The uimd-build skill also now picks the whole screen (its recipe and
skeleton) before the parts, states that plan before writing code, and ends
with a short report: the parts used, anything built by hand, what was logged,
and decisions for you.
Page composition rules; `SiteHeader` text actions stop wrapping
v2026-10-05.8
docs/ui-md-rules.md gains a "Page composition" section: the skeleton for a
marketing page, an app page and a reading page, built from parts the kit
ships, plus rules for content width, centring, the hero, feature rows,
pricing, logo rows and app list pages. The job router sends marketing pages
and product screens to it.
Moves on screen
- Items in
SiteHeader's right-hand slot no longer wrap. A crowded bar used to squeeze a two-word link like "Log in" onto two lines at phone width; it now stays on one line, and a bar that still does not fit overflows instead. If yours does, hide the text actions belowmd(hidden md:inline-flex) and move them intoSiteNav, which Page composition in the rules now says. In the defaultnavPlacement="start"layout the slot's children now sit inside one wrapperdiv, with the same 16px gaps as before.
Display type carries its own letter-spacing
v2026-10-05.7
Moves on screen
- Your own
text-displayandtext-jumbotext is set slightly tighter: -0.025em instead of the body's -0.01em. Kit parts look the same (Lockup's large title already used that value). If you addedtracking-tightnext totext-displayortext-jumbo, you can delete it.
Surfaces keep their edge in high contrast mode
v2026-10-05.6
Moves on screen
- Only in Windows high contrast mode: cards, popovers, menus, dialogs, sheets, drawers,
badges with an edge, and code blocks in
Prosenow show a 1px outline. Before, their edge was a box-shadow, which high contrast mode removes, so they had no edge at all. Normal rendering is unchanged.
Motion roles: small and large
v2026-10-05.5
New tokens: --transition-duration-small, --ease-small, --transition-duration-large and
--ease-large. Set one in your own CSS to speed up or slow down the whole kit at once.
Combobox can be disabled or invalid; read-only fields look read-only
v2026-10-05.4
Moves on screen
- A read-only
InputorTextareanow sits flat on the muted fill with no shadow. It used to look exactly like an editable field. Comboboxtakesdisabled(dims to 70% and will not open) andaria-invalid(destructive border and ring, as on Input and Select). Nothing changes until you pass them.
Focus rings and switch tracks you can see
v2026-10-05.3
Moves on screen
- The focus ring is darker in light mode and lighter in dark mode.
--ring-focusmoved from neutral-300 (light) and neutral-700 (dark) to neutral-500 in both. The old ring was under 2:1 against the page and failed the 3:1 a keyboard user needs to find focus. - An off
Switchhas a darker track in light mode and a lighter one in dark mode, for the same reason: neutral-500 in both, up from neutral-300 / neutral-700. - If you set
--ring-focusin your own CSS, keep it at 3:1 against--backgroundand--cardin both modes.
The token file lands in `src/app` when that is where your app is
v2026-10-05.2
A project that keeps its app in src/ got ui-md-tokens.css written to a new
app/ folder at the project root on every pull, beside the real one in
src/app/, which was never updated. The kit now writes it to src/app/ in a
src project and app/ otherwise. If a pull left an app/ui-md-tokens.css
at your project root and your app lives in src/app/, delete it, and pull
once more so src/app/ui-md-tokens.css is current.
A scroll region no longer shears a ring at its edge
v2026-10-05.1
Moves on screen
- A vertical
ScrollFadeno longer cuts off a ring drawn outside a child's box. ABadge(its 1px edge), a focusedButton(its 3px ring) or a focused field sitting flush against the region's left or top edge used to lose that side. The region now reaches 4px past its box and pads it back, so the content does not move and the ring shows whole. Seen first on aBadgeat the top of aSheetbody. - The vertical scroll thumb sits where it did. A horizontal
ScrollFade(aTable, aCodeBlock) is unchanged. - If you added padding inside a
ScrollFadeto clear a ring yourself, delete it.
Phone widths
v2026-10-02.3
Every example in the kit was checked at 390px and 320px wide. Five components changed how they behave when there is not much room.
New: Table takes stack. Below 32rem of table width each row becomes a
title line plus one labelled line per cell, labelled from the header row; for
lists of records. ScrollFade now publishes --sf-scroll-left /
--sf-scroll-top, how far the region has scrolled (capped at the skim).
Moves on screen
PageHeader: with anaction, the action drops under the description when the title would get narrower than 240px beside it. It used to stay on the right and squeeze the title to a word or two per line.Pagination: when the numbered row does not fit, it turns into a compact row: previous, "Page 3 of 10", next. The switch point depends onsiblingsand reads the pager's own width, so a pager in a narrow card switches too. The pager now always fills its row (w-full), which is what it measures: in a flex row it no longer hugs its content, and with no count or page size it centres in the space it takes.Carousel: belowsm, the default (bare) arrows sit inside the slide, so they now take thechiplook there: a round filled button. Half-opacity chevrons on top of artwork could not be seen.Table: a narrow table fits before it scrolls. Below thexlcontainer width (42rem) the column minimums drop from 160 / 128px to 112 / 80px, so a short three-column table on a phone no longer scrolls. A table that still does not fit scrolls inside a horizontalScrollFadewith its first column pinned: the cut edge fades, and a hairline appears on the pinned column only while content slides under it.table-containerno longer scrolls itself. AstickyHeadertable, and asurface="bare"one for the pinning, are unchanged. Installingtablenow also installsscroll-fade.TableToolbar: below thexlcontainer width (42rem), the controls wrap with the rest of the row, and a search field grows from a 224px floor instead of keeping its fixed width. It used to squeeze to a cut-off placeholder beside a select.
A button that swaps its face at one width
v2026-10-02.2
New: SwapButton, a button whose icon and label change with a state. Every
face is stacked in one cell and centred, so the button is as wide as its
widest face and never changes width. The new face fades in; the old one drops
out at once. CopyLabelButton is now built on it.
Moves on screen
CopyLabelButton: the shorter face is now centred in the button instead of sitting against the left edge. Its gap and icon-side padding are unchanged:SwapButtonreads both from the same tables Button uses (buttonGapandbuttonIconSidePadding, now exported frombutton-variants).
A copy button with a label
v2026-10-02.1
New: CopyLabelButton, a labelled button that copies text and confirms in
place. The copy icon fades to a check and the label changes to Copied, then
both return. The button holds the wider label's width, so nothing beside it
moves. CopyButton is unchanged and stays the icon-only control for code
samples.
One name per prop idea
v2026-10-01.5
Eight props had two names for the same idea across components. Each now has one. The old names still work and are marked deprecated, so your editor will point at them. They will be removed in a later release.
EditPopover:avoidCollisionsis nowcollisionAvoidance, the same objectPopoverandDropdownMenutake.avoidCollisions={false}still maps to{ side: "none", align: "none" }.Button:badgeis nowcount, andbadgeClassNameis nowcountClassName, matchingNavandTabs.PageHeader:as="h2"is nowlevel={2}, matchingLockupandFaq. It takes any level from 1 to 6.PageTitleRow:actionsis nowaction, matchingCard,PageHeaderandMetricCard.SiteHeader:hairlineis nowdivider, matchingSectionandSiteFooterRow.SegmentedControl:optionsis nowitems, matchingCombobox.SelectionCardGroup:typeis nowmode, matchingCalendar.showToastoptions:dismissAfteris nowduration, matching Sonner.
Tables on a phone scroll instead of squeezing
v2026-10-01.4
Moves on screen
- A
Tabletext column no longer narrows to one word per line on a small screen. Text columns keep at least 128px, and the first column at least 160px, so the table scrolls sideways instead. On a wide screen nothing changes, since columns are already wider than that. - Numbers, centred columns, checkbox columns, an empty actions header, and any
column with its own
widthkeep their old sizing. - Default-density cells have 16px of side padding, down from 24px, when the table itself is under 576px wide. A table in a narrow sheet gets this too, not just a phone.
Code blocks scroll sideways under the kit's own scrollbar
v2026-10-01.3
ScrollFade takes a new orientation prop ("vertical" default, or
"horizontal") and fade accepts "start" and "end". Vertical behavior is
unchanged. code-block now depends on scroll-fade.
Moves on screen
- A
CodeBlockline that runs past the edge now scrolls under the kit's overlay thumb (fades in on scroll, out after a second, draggable) instead of the browser's scrollbar, with no reserved gutter. - The text fades out toward the right edge so it no longer shows behind the copy button, and a left fade appears once scrolled. At the end of the scroll the right fade is gone.
- Every
CodeBlockhas 48px of right padding, up from 16px (on touch it was already 48px). The last characters scroll clear of the button.
Week schedule: event details, its own scrollbar, even gaps
v2026-10-01.2
Clicking an event opens its details in a popover beside it: the title,
the full day and time, meta, and the new description field. A popover,
not a dialog, because it is detail about one thing on the calendar, and the
calendar should stay in view and usable around it. eventDetails={false}
turns it off for a page that opens its own Sheet from onEventClick. With
neither, events stay plain content rather than buttons that do nothing.
Moves on screen
- Every timed event in
WeekSchedulesits 2px clear of the hour line above AND below it. It used to touch the line under it, and aneutralevent touched both, because its edge ring painted outside the box. The ring is now drawn inside. - All-day bars are 20px, the height of a one-line timed event.
- The hour grid scrolls with the kit's
ScrollFadethumb instead of the browser's scrollbar.
The kit ships a week schedule
v2026-10-01.1
WeekSchedule is new, and it is in kit. A calendar week: seven days
side by side on an hour grid, with each event placed and sized by its times, an
all-day row across the top and a line at the current time. A meeting calendar, a
content calendar, bookings or shifts. Calendar is a date picker and was never
the answer to this.
<WeekSchedule
week={week}
events={[{ id: "1", title: "Design review", start: new Date(2026, 9, 6, 10, 30), end: new Date(2026, 9, 6, 12) }]}
onEventClick={(e) => setSelected(e.id)}
onWeekChange={setWeek}
/>
Events that overlap split the column side by side. Under 768px of its own width
it becomes an agenda list by day, so a phone never scrolls sideways. Pass
onWeekChange and the header gets Today, previous and next. selectedId is an
accent ring on the event, so opening its detail moves nothing.
Pass timeZone on any server-rendered page. Without it the server draws the
week in its own zone and the browser in the viewer's, and React throws the
first render away.
A link styled with buttonVariants loses its side padding
v2026-09-30.4
Moves on screen
- A text link painted with
buttonVariants({ variant: "link" })loses 16px of side padding.<Link className={buttonVariants({ variant: "link" })}>, the idiom for a link in a Server Component, kept the default size'spx-4because the class list was joined, not merged, and Tailwind emitspx-4afterp-0. A "Forgot password?" link beside a label therefore sat 16px short of the field's right edge. It now lines up with the edge.Buttonwas never affected. If you addedpx-0or-mr-4to compensate, delete it. buttonVariantsnow returns the same merged class listButtonbuilds, so a link painted this way matches aButtonof the same variant and size. Where two classes used to fight (the link rule's height, padding and corner against the size's, or a straytext-xsunder the size's own text size), only the winner is left. If a link painted this way changes size, that is the cause. AclassNameyou pass still wins over the variant.
Clearer component index lines
v2026-09-30.3
New build failures
None.
The component index in docs/ui-md-rules.md now says when to reach for seven
parts it described too loosely: SiteHeader (a marketing or docs top bar, not an
app shell) and Nav (the sidebar of an app shell), SiteNav (the phone menu, not a bare Drawer),
Brand (mattdowney.com projects only), Toaster (mount once, for
showToast), Toast, Hr and Separator (each names the other), and Badge.
No component code changed.
A form in a card gets room, and field icons grow
v2026-09-30.2
Moves on screen
- A form in a card sits 8px lower.
CardContentdirectly under a borderlessCardHeaderused to start 8px below the description, whatever it held. That is right for prose, and it put a form's first label on the description as if it were one more line. WhenCardContentholds a field (an input, textarea, select, switch, checkbox, radio group,Label, or a Select/Combobox trigger), the gap is now 16px, theclusterrung. Prose still gets 8px, and a bordered header still gets the full 20px. If you reopened this gap yourself with[[data-slot=card-header]:not(.border-b)+&]:pt-*, the kit's rule now outranks it for a form: delete yours. - Input icons follow Button's size ramp.
iconStartandiconEndwere a flat 12px at every size. They are now 12 / 14 / 14 / 16px acrossxs/sm/default/lg, the same as a Button of that size. A glyph insideactionEnd(a reveal-password or copy button) is resized to match. Text next to an icon moves over to keep the 8px gap: 32px (unchanged) atxs, 34px (was- at
smanddefault, 36px (was 32) atlg. Text besideactionEnddoes not move.
- at
A copy rules file, a job router, and screen recipes
v2026-09-30.1
New build failures
None.
Three additions to the rules an agent reads. No component or token changed.
docs/ui-md-writing.mdis new. It holds the portable rules for interface copy: verb-first button labels, sentence case, how to word an error, an empty state with one next action, and a confirmation that repeats the consequence instead of asking Yes or No. If your project has adocs/voice.md, it wins for wording. TheAGENTS.mdblock gains two lines pointing at both, and it is replaced on this pull like any other.docs/ui-md-rules.mdgains a "Where to start, by job" table under "Start here", and a "When the rules do not cover it" section. Theuimd-buildskill now sends an agent to the table instead of a fixed list, and asks it to name any call no rule covered.- The component index now lists whole-screen recipes (settings, dashboard, landing), each linking to a working page on ui.mattdowney.com and naming the kit parts it is built from.
`CardFooter` takes a `size`
v2026-09-25.1
New build failures
None.
CardFooter gains size="compact": a bordered footer with 12px above and
below its actions instead of 20px, so the band is 52px with a sm button
rather than 68px. It is for a dense grid of cards such as a dashboard, where
that band repeats under every card. Reach for it instead of overriding the
footer's padding through className.
Bar `Sparkline` keeps thin bars on a short series
v2026-09-24.3
Moves on screen
- A bar
Sparkline(including aMetricCardtrend) with fewer than 12 points no longer stretches its bars across the card. Each bar keeps the width it would have at 12 points, and the bars sit against the right edge, newest last. Before, a short series drew wide bars whose rounded ends turned into ovals. Series of 12 or more points look the same.
`MetricCard` goal line stays on one line
v2026-09-24.2
Moves on screen
- A
MetricCard(orMetricCardValue) goal line too long for its card now ends in an ellipsis instead of wrapping to a second line. Short goal lines look the same. - If you shortened a long goal line yourself with
truncate, remove it: its overflow-hidden cut the bottoms off letters like p and g, because the line is cap-trimmed. The kit now does the shortening without that clip.
`SelectionCard` gains a `media` variant
v2026-09-24.1
Autofilled fields stop turning blue
v2026-09-17.2
Moves on screen
- An autofilled
input,textarea, orselectno longer shows Chrome's pale blue fill on a non-white surface. The token file clips the browser's forced background to the text (-webkit-background-clip: text) and fills the glyphs and caret with--foreground, so the field keeps its ownbg-input-bg,shadow-xs, and focus ring. This is not the classicbox-shadow: insetautofill hack: that hack paints over an element's own box-shadow, and Input carries one (shadow-xs) plus a focus ring (also a box-shadow in Tailwind v4), so it would have silently deleted both on autofill.
`Lockup` gains a `page` size
v2026-09-17.1
Text colour follows the text's job
v2026-09-15.1
A code block or table inside a list item in `Prose` gets room
v2026-09-11.1
Moves on screen
A code block or table nested in a list item now sits 12px clear of the
text above and below it. It had no gap at all: the block's spacing only
reached figures that are direct children of the Prose root, so a fenced
block under a bullet drew its ringed edge flush against the bullet's line.
Top-level code blocks and tables, and the 8px between list items, are
unchanged.
`SegmentedControl` joins the control ramp
v2026-09-08.1
Moves on screen
Every SegmentedControl is shorter, squarer, and its selected option is
accent-tinted instead of a raised white thumb. The widget now measures the
same as a Button of the same size name, 28 / 32 / 40, where it measured 38 /
42 / 50 before. It used to size its options to the rung and let the track add
padding on top, the way Tabs does, so a sm control stood 10px taller than
the sm Button in the same title row. Corners come off the action ramp
(rounded-action-sm / rounded-action / rounded-action-lg, options one
step in) rather than rounded-full, so it reads as the same shape family as
the button beside it. The thumb is now Tabs' indicator, accent-100 in light
and accent-800 in dark under an accent label, with no shadow; the track is
the same neutral well a TabsList sits in. iconOnly options are no longer
forced square, since that rule only existed to keep a circle from reading as
an oval. The onMedia tone keeps its black and white but takes the same
radius and loses the thumb shadow. If a control was placed with a hand-tuned
offset to line up with its neighbours, that offset is now wrong: delete it.
Inline code in `Prose` is the docs' accent chip
v2026-09-04.2
Moves on screen
Inline code inside Prose now renders as the same chip the docs site
uses for prop names and class names: accent tint (accent-100, accent-900
in dark), accent label, medium weight, 0.85em of the surrounding text. It
was a neutral grey chip that inherited the sentence's size, so a code word in
rendered markdown and the same word in an API table on the same page read as
two systems. A monospace face also sets wider and taller than the sans at the
same nominal size, so the inherited chip read a step large. Code blocks are
untouched; they set their own size.
Vertical Stepper rail fills from `value`, not item state
v2026-09-04.1
Moves on screen
A vertical Stepper's connector below a step now fills whenever that step is
below value, matching the horizontal rail. It used to fill only when the
item's own state resolved to completed, so the documented state
override (re-reading a finished step as active) greyed the segment under
that step while later segments stayed green, which read as the flow having
skipped one. A stepper that never passes state renders exactly as before.
ThemeToggle stops drawing its own icons
v2026-09-03.4
Moves on screen
ThemeToggle no longer animates, and it no longer draws its own glyphs. It
imports IconSunHighFilled, IconMoonFilled and IconKeyboardFilled and swaps
between them instantly. Same box, same sizes, same cycle.
It used to paint one circle that scaled up while a masking circle slid across it to carve a crescent, with eight rays retracting into it. That drawing was a hand-traced copy of Tabler's path data, and a copy has to be re-traced every time the set moves. Nothing gates one against the real glyph: it had already drifted into a visibly different sun with typecheck, verify-ui and the build all green. Importing the icons removes the whole class of problem.
Two of the three glyphs also changed. The sun was IconSunFilled, whose
rays are short stubs running r=7 to r=9; it is now IconSunHighFilled, whose
rays run r=7 to r=11, so the sun reads larger at the 12-16px sizes a menu row
and a segmented pill actually use. system was IconDeviceDesktopFilled and is
now IconKeyboardFilled: the row means "whatever this machine is set to", and
half the machines reading it have no monitor to draw.
The system dot is gone. When the preference is system the button now shows
the keyboard, the same glyph the segmented and menu forms put on that row. The
dot only existed because a two-ended animation had nowhere to put a third state,
and the docs already called it weak signage.
resolvedTheme is now optional. The glyph is a function of the stored
preference alone, so nothing breaks if you drop it. Keep passing it and the
accessible name still reports "system (dark)" instead of just "system". Existing
call sites need no change.
theme-toggle now declares @tabler/icons-react, which is already in the
kit's universal set, so it stays in kit and pulls in nothing new.
If you built the settings or menu form from the docs, swap those two imports
too, or a stale IconSunFilled sits beside the updated button.
Where the theme control goes when it is not in the header
v2026-09-03.3
The surface picks the theme control, and there are three of them. The kit
shipped ThemeToggle and said only that it switches light and dark, so the one
question every app actually asks, where does it go, had no answer anywhere.
A header is the easy case. An app with a sign-in puts the control in the account
menu instead, and nothing said what it should look like there.
- Header or other bare chrome:
ThemeToggle. One tap, no label, the glyph is the state. - Inside a menu (account, overflow): a
DropdownMenuSublabelled "Theme" holding a radio group of Light, Dark and System. NotThemeToggle. A menu is a column of named rows, so an icon-only cycle button is the one thing in it with no name, sitting under "Billing" and over "Sign out". - A settings surface: a
SegmentedControlover the same three values.
Ship one of the three per app, not two. If there is an account menu, the theme lives in it and not also in the header: two visible copies of one preference is the defect, not the convenience.
A correction, and it is the reason this entry exists. The doc on
DropdownMenuSwitchItem called a "Dark mode" switch its canonical case. That
was wrong for most apps and is now fixed in the source. A switch is a boolean
and a theme preference usually is not: the moment someone flips it the value is
a side, and no row is left that hands the choice back to the OS. If you built a
"Dark mode" switch row on that advice and your app supports system, that row
is a one-way door. The switch is right only where the app stores light and dark
and never asks the OS at all.
All three forms use the same glyphs, and they are the filled ones:
IconSunFilled, IconMoonFilled, IconDeviceDesktopFilled. This is the one
place the kit leaves its outline icon set, and it is forced rather than chosen:
ThemeToggle carves its moon by masking a disc, and masking a stroked circle
leaves an open-ended arc instead of a crescent. The component draws those two
icons rather than importing them, because it animates between them, but it now
paints their exact geometry at both ends. If you built the settings form on the
earlier docs it used the outline IconSun and IconMoon; swap them for the
filled pair, or a hollow sun sits next to a solid one on the same screen.
The rules file carries the ruling under Component conventions, the component
index entry for ThemeToggle now names the surface it belongs to, and the Theme
Toggle page has an "In a menu" example built from the shipped submenu parts. No
new component: every part of all three forms already shipped.
Moves on screen
ThemeToggle's sun and moon are redrawn to Tabler's exact geometry, and
this is the one thing in the entry you will see without touching your code.
Its rays were eight stroked lines that, with round caps, reached from the disc's
edge out to r=9, so they touched the disc and the sun read as one solid spiky
star. IconSunFilled draws detached stubs from r=7 to r=9, with a clear gap.
The two were visibly different suns the moment anyone put the segmented form
next to the button, which is how this was caught. The moon grew to match too:
its disc now scales to IconMoonFilled's r=10 outer circle rather than to 7.5,
cut by the same 6.5 circle Tabler uses.
The sun therefore reads slightly lighter and the moon noticeably larger. Nothing about the box, the sizes, the animation or the API changed.
The kit ships a theme control
v2026-09-03.2
ThemeToggle is new, and it is in kit, not kit-extras. The kit has
shipped a light and dark token layer since the beginning and nothing to drive
it, so every project built its own switch. This is that switch.
It is drawn as ONE shape rather than two. Almost every theme toggle stacks a sun
and a moon in one box and crossfades them, which shows you both glyphs at once
halfway through. Here a single circle grows while a masking circle slides across
it to carve the crescent, and the rays retract into the disc: a sun and a moon
are the two ends of one animation. Transform and opacity only, 200ms, and it
holds still under prefers-reduced-motion.
It is controlled, and that is why it is in the core. It takes theme and
resolvedTheme and hands you the next value through onThemeChange, so it
declares no theme library and forces no package on you. Wiring it to next-themes
is about six lines, and the doc page has them.
<ThemeToggle theme={theme} resolvedTheme={resolvedTheme} onThemeChange={setTheme} />
Pass null for both until the client has read the stored preference. Every
theme library reads it on the client, so the server renders one theme and the
first client render has another. Guess, and the wrong glyph paints and then
morphs on load, which is a visible flip on every page view in the losing theme.
null gives you the same full-size box with no glyph and the button inert: no
layout shift, no flip, and no placeholder for you to build.
system is opt in, through themes. The default is the plain light/dark
swap. Pass themes={["light", "dark", "system"]} and the same button gains a
third stop, marked with a dot on the glyph's corner. This is worth taking if
your app has a system default: with a binary toggle, someone who clicks once can
never hand the choice back to the OS again.
Be honest about what that dot can carry, though. A cycle button cannot say
"System" out loud, and a dot is weak signage for a word. Where the third choice
matters (a settings page, an appearance panel), compose SegmentedControl
instead and let each option name itself. Same three values, same setter, and
nothing new to install:
<SegmentedControl
value={theme}
onValueChange={setTheme}
options={[
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
]}
aria-label="Theme"
/>
The button is the chrome form, the pill is the settings form. Both are on the doc page.
SegmentedControl options now take an icon, and the control takes
iconOnly. That is what makes the pill above work at toolbar width. An
icon renders before the label; iconOnly drops the visible labels and keeps
each one as that option's accessible name and hover title, so a bare control is
never a nameless one.
<SegmentedControl
value={view}
onValueChange={setView}
options={[
{ value: "grid", label: "Grid", icon: <Grid /> },
{ value: "list", label: "List", icon: <List /> },
]}
iconOnly
aria-label="View"
/>
iconOnly is a GROUP-level prop, unlike Tabs, which derives icon-only from a
trigger having no children. A segmented control is read as one row of peers, so
a mix of labelled and bare options is a defect rather than a layout to support.
Icon options also part from Tabs in one measurement: they take an explicit width
equal to their height (28 / 32 / 40) rather than symmetric padding. A Tabs
trigger is rounded-md, where a slightly oblong icon pill reads fine; these are
rounded-full, where anything but a square renders as a visible oval sitting in
a row of circles.
Every other value on an option is the one a Tabs trigger uses at the same rung, so a segmented control and a tab strip in one toolbar stay the same object at the same scale.
The BOX reads the icon-only rungs of the action ramp, so size="sm", default
and lg are 24 / 32 / 40px and line up with a Button of the same rung beside
them. The hit area grows past 44px on a coarse pointer through a pseudo-element,
so a phone gets its target without the bar reflowing.
The GLYPH deliberately does not read Button's ramp. Button paints a fill, so its 12 / 14 / 16 sits inside a visible pill and the space around it is that pill's padding. This control paints no fill, so the box is invisible and the glyph carries the whole read: it runs 16 / 20 / 24, about 62% of the box, which is where the kit's other bare icon controls already sit. If you are building your own fill-less icon control, that ratio is the one to copy, not Button's.
Ray stroke comes from --stroke-icon, so retuning that token moves this glyph
with every other one. Painted ink is strokeWidth * box / 24, which is why a
hand-set number here would silently stop matching its neighbours the moment the
token changed.
Three motions, three easings, and they are not interchangeable. The sun-to-moon
morph is ease-in-out: the glyph is already on screen and both ends of the move
are visible, the same call Progress makes for its bar. The system dot is
ease-out, because it arrives and leaves rather than travelling. The press is
transform inside the transition list at 150ms, so it eases exactly as the
Button beside it does, at the kit's 0.98 press depth. Under
prefers-reduced-motion the morph and the dot stop dead, the press scale is
removed outright, and only the colour fade survives, which is the split the rule
asks for: colour carries meaning, movement does not.
Moves on screen
Nothing, in practice. ThemeToggle is a new component and no existing markup
renders it, and the SegmentedControl additions below are opt in: an option
with no icon renders exactly the classes it did before.
One edge worth naming rather than burying. A segmented option now sizes any
svg inside it (size-3 / size-3.5 / size-4 by rung, skipped where the
icon sets its own size-*). label is typed string, so this reaches you only
if you were passing an element through it from untyped code. If you were, give
that icon an explicit size-* or move it to the new icon field.
The horizontal stepper gets a measure, and its rail gets one colour
v2026-09-03.1
Moves on screen
A horizontal Stepper label now wraps inside a fixed measure, 128px at the
default size and 112px at size="sm", instead of sitting in a zero-width box
that could hold only one short line. The box still gives back exactly as much
margin as it takes in width, so it contributes nothing to the row and the dots
stay evenly spaced with the first flush left and the last flush right. Nothing
about your markup changes.
Take this one if you have ever put more than one word in a horizontal
StepperTitle, because until now it did not render the way it looked in the
code. whitespace-nowrap on the label box and text-pretty on StepperTitle
are both shorthands over the same longhand, text-wrap-mode. The box set it to
nowrap, the title set it back to wrap, and a declaration on an element beats the
value it inherits: the title was free to wrap, and in a zero-width box it wrapped
once per space. A three-word title came out as three stacked lines. Under the
measure it wraps where a reader would put the break.
The measure is fixed and does not grow with the container, so a label longer
than a phrase still belongs in orientation="vertical". That was always true;
it just used to fail by overflowing into the next step rather than by wrapping.
The completed step's ring, its check, and the connector behind it are now one
colour, success-500, in both themes. The ring used to be a success-300
around a success-500 check, which is two greens in one 32px circle for no
reason a reader could name, and the 300 was also the weakest mark on the light
page at 1.66:1. If you had themed --color-success-300 to tune that ring, it no
longer reaches the stepper; theme the 500 instead. Completed steps still recede,
because they are still an outline on the page background with no fill: the quiet
comes from how little ink they use, not from how little contrast.
Worth knowing if you are auditing: the 500 measures 2.85:1 against the light
background, just under the 3:1 that WCAG 1.4.11 asks of a graphic carrying
meaning. That is a chosen trade, and the state does not rest on colour alone.
A completed step swaps its numeral for a check, which is a shape change, and the
indicator's screen-reader text says "Completed:" outright. If you need the light
rail to clear 3:1 on its own, override all three parts to success-600
together.
The connector running into the active step no longer blends from green to the accent. Every filled connector is the one success colour end to end. A filled connector means the segment is behind you, and everything behind you is done; the blend said the segment itself was in progress, which it never was, the step was. The junction it was smoothing does not need smoothing, since the active indicator carries a 4px accent halo outside its border and the line ends underneath it.
The rules file gets an index, because reading it was not enough
v2026-08-31.5
What is new
docs/ui-md-rules.md now opens with a component index, under "Reach for
the component first": one row per component, keyed by the SITUATION you are
building rather than by the component's name.
It exists because the rule it enforces was already in the file and was being
broken by agents that had read it. The recorded case is a consumer's sign-in
page built out of a Stack and four rhythm rungs, by an agent that had just
read every section the uimd-build skill names. Lockup ships that page
whole, and appeared in the rules file's other 85,000 characters exactly twice,
both times incidentally, inside rules about other things.
The failure is retrieval, not comprehension, and that is why more prose was
not the fix. An agent reaches for what it can name, so it reaches for
Button and Stack and hand-builds the rest. The file was organised by rule
(spacing, type, radius), which is the wrong axis for the question actually
being asked, and at 85,000 characters adding another rule section makes
retrieval worse rather than better.
The index is GENERATED from a @use-when marker in each component's own
source, so the description lives beside the thing it describes and a second
place to describe every component never exists. All 60 are seeded, and the
generator FAILS on a component that carries no marker: an index with holes is
worse than no index, because a miss reads as "the kit has nothing for this".
Nothing to do on your side. The index arrives with the rules file on your next pull.
New build failures
One new rule, hand-rolled-composite, and like every new rule it lands as a
warning first. --strict promotes it now.
It fires on a heading, then a paragraph, then an action, as adjacent siblings
in one container. That shape is Lockup rebuilt by hand: the kit's stack for a
prompt standing in for content, an empty result, an error, a success screen, a
sign-in gate. Lockup owns that whole rhythm and ramps all four gaps by size.
The rule is deliberately narrow and will not catch every hand-built
composite. Only whitespace and JSX comments may sit between the three parts,
and each of the first two must hold plain text, because the same
heading-then-paragraph shape is what ordinary prose looks like and a style rule
that over-fires gets switched off in every repo that pulls it. A draft without
those limits matched across sibling boundaries, walking from one component's
heading to the next component's button and reporting a composite nobody wrote.
A heading, paragraph and link inside real prose carries a
ui-md-allow-hand-rolled comment.
Stepper completed steps go soft
v2026-08-31.4
Moves on screen
stepper completed indicators drop from solid success-500 with a white check
to an outline: the circle keeps the theme background, with a 2px success-300
edge matching the connector's color and width and a success-500 check (in
dark mode edge and connector both hold the check's 500; muted rungs read as
murk on a near-black canvas). The connector fill lightens with them
(success-300 in light), including the gradient end that blends into the
active step. A long run of finished steps now recedes so the active step
carries the weight; before, ten solid green discs were the loudest thing on
screen. The active step's halo doubles its alpha in dark
(ring-accent-500/30); 15% was invisible there.
The check's stroke is heavier, and the reason is worth knowing if you set icon
stroke anywhere. A Tabler glyph paints strokeWidth * box / 24 pixels of ink,
so the nominal 2 it shipped with painted only 1.33px inside the 32px
indicator and 1.0px at size="sm", against a ring and connector that both
paint a true 2px. The check read thin and small next to its own rail. It now
sets a stroke that inverts the formula (3 at the 16px box, 4 at 12px) to land
exactly 2px at both sizes, on whole device pixels at 2x. The box is unchanged.
That stroke is pinned to the ring rather than reading --stroke-icon, and
deliberately so: that token is matched to a font weight, so retuning it for
labels would drift this check off the 2px ring beside it.
A current-page marker for the footer
v2026-08-31.3
The accent label picks its own contrast
v2026-08-31.2
Moves on screen
Nothing, unless you retune the accent. --accent-foreground (the label on a
solid accent fill, e.g. the primary Button) is no longer hardcoded white. On
browsers that support OKLCH relative color (Chrome 119+, Safari 16.4+, Firefox
128+) it reads the accent's own lightness and snaps to black or white at a
threshold, so a light accent gets a dark label instead of an unreadable white
one. The shipped accent is dark, so its label stays white and nothing moves.
Retune --accent to a light color and the label flips to black on its own, with
no per-color hand-tuning. Older browsers keep the white fallback.
This pairs the label to the fill wherever the fill is --accent (the primary
Button's light-theme rest, and any consumer surface using bg-accent text-accent-foreground). The primary Button's DARK rest still uses the tuned
accent-600 plate, which is dark, so its white label is correct and unchanged;
if you retune the dark fill to a light color, point that plate at --accent so
the paired label follows.
An AGENTS.md pointer and a "Start here" gate
v2026-08-31.1
What is new
An agent that runs the install now wires one managed block into your
AGENTS.md. It is fenced with <!-- ui-md:begin --> / <!-- ui-md:end -->
markers and holds a two-line pointer: read docs/ui-md-rules.md before building
UI, and follow docs/ui-md-upgrade.md to upgrade. This is the one hook Codex,
Cursor, and other agents read, and it is what stops a fresh project from
hand-rolling components while the kit's rules sit unread on disk.
It is safe by construction. The kit only ever touches what is between its two
markers: on a first install it appends the block (or creates AGENTS.md if you
have none), and on every later pull it replaces just that block to keep the
pointer current. Everything you wrote above or below it is never touched, and
the file is never overwritten. If you would rather it stay out, delete the
block; keep the markers empty and a pull leaves them empty.
The rules file opens with a short "Start here" gate. The top of
docs/ui-md-rules.md now leads with the six rules an agent breaks by reaching
for another project's defaults (use the kit's components, space with the rhythm
rungs, size type from the scale, elevation is shadow-ring-*, no alpha on
semantic tokens, run the checker), so an agent that opens the file reads the
mandate before the detail.
Icons get crisp again, and the icon rename hazard
v2026-08-28.1
Also
--icon-optical-scale is now in the rules doc, under the icon stroke
ladder, where it should have been when it shipped. That ladder derives stroke 2
from Geist's stem (24 x 0.084 = 2.02) and tells you to retune the token rather
than a call site, and it was silent about a third token that multiplies both
rungs by 1.1.
The entry now carries the painted-pixel table, because the token values alone
lead you to the wrong conclusion. The ladder is stated at icon box == font size, and no real control is built that way: Button runs 12/14/14/16px of icon
against 11/12/13/14px of type. Compared in painted ink against a semibold
label, the action rung reaches 79-85% of the label's stem on its own and 87-94%
with the scale, so the scale closes part of an existing shortfall rather than
overshooting, and turning it off makes icons lighter than the words beside
them rather than better matched.
What it does cost is the pixel grid, and that is the real reason to turn it
off. A tenth is the worst multiplier available: at 2x, scale: 1 paints
Button's icon boxes at exactly 24 / 28 / 28 / 32 device pixels with lg's
stroke at exactly 3, while 1.1 paints 26.4 / 30.8 / 30.8 / 35.2 with every
stroke fractional, so both edges of every line land mid-pixel and the glyph
renders soft. Largest is worst, because the fringe is the same width on a
bigger drawing. --icon-optical-scale: 1 takes that trade.
Two things the scale does NOT touch, so nobody compensates for it twice: the
counter ceiling is a ratio and a uniform scale moves gap and stroke together,
and the property is the individual scale rather than transform, so it still
composes with a chevron's rotate.
Moves on screen
Every icon in the kit is about a tenth smaller and renders crisply.
--icon-optical-scale ships at 1 where it shipped at 1.1 the day before.
No per-component opt out; set the token to 1.1 in your own CSS to put it back.
The 1.1 was there to fill the box the way lucide did, and it does. What it also
does is multiply every icon size by a tenth, and a tenth is the worst
multiplier available on a pixel grid. At 2x, scale 1 paints Button's four icon
boxes at exactly 24 / 28 / 28 / 32 device pixels with lg's stroke at exactly
3; at 1.1 they are 26.4 / 30.8 / 30.8 / 35.2 with every stroke fractional, so
both edges of every line land mid-pixel and the glyph renders soft. The biggest
button looked worst, because the same fringe covers a bigger drawing.
This is a trade and the entry says so, because the case for 1.1 is real.
Measured in painted ink against a font-semibold label's stem, the action
stroke reaches 79-85% of it at scale 1 and 87-94% at 1.1: the icons really are
lighter than the words beside them now. The reasoning for shipping crisp anyway
is that a weight 8 points light is something you notice if you go looking,
where a soft icon is something you notice immediately. Raising
--stroke-icon-action instead does not work: holding lg's stroke on the grid
allows only 2.25 (what ships) and 3.0, and 3.0 closes the trash lid's counter
to 0.63px, under the ceiling in the rules file.
auth-dialog is the other component edited, and its output is identical: the
error copy it keeps on screen while the panel collapses moved from a ref
written and read during render to state adjusted during render, React's own
documented pattern for exactly this. The ref version drew two
react-hooks/refs errors in any project linting with the React hooks plugin,
which this repo does not, so it shipped clean from here and failed in a
consumer. It was also a real correctness risk rather than a lint technicality:
a ref write is invisible to React, so a render that gets thrown away could
leave the retained message set to one the user never saw.
New build failures
One new rule, icon-name-in-copy, and like every new rule it lands as a
warning first. --strict promotes it now.
It exists because of what the previous entry costs you when you follow it.
Moving your OWN icon imports from lucide to Tabler means renaming Inbox to
IconInbox across your app, and the obvious way to do that is a find and
replace on the word. A word replace cannot tell an identifier from the same
letters inside a label, a placeholder, an aria-label, or a comment. The
consumer this came from shipped 47 of them across 22 files: a nav reading
IconInbox and IconCalendar, search fields placeheld with IconSearch…, a
Save button labelled IconDeviceFloppy, and an aria-label reading
IconSearch archived captures. Every check they had passed, because the code
is correct: it type-checks, it lints, and it renders. Only the words are wrong.
The rule reads strings and JSX text, and deliberately not comments, because a
comment is not copy and legitimately names types like IconProp. A string that
holds a code sample is skipped, so a docs site showing <IconStar /> is quiet.
A picker or a table that lists icon names on purpose carries a
ui-md-allow-icon-name comment.
If you are about to do this migration, rename by identifier rather than by word: your editor's rename symbol, or a replace anchored on the import statement. Then run the checker and fix whatever copy the rename reached anyway.
The icon set is Tabler, not lucide
v2026-08-27.1
Moves on screen
Every icon in every kit component is now a Tabler glyph (@tabler/icons-react)
where it was a lucide one. This is a whole-kit rendering change with no
per-component opt out: buttons, inputs, menus, the dialog close, table sorters,
every place the kit draws an icon now draws Tabler's version of it.
The measurable parts are unchanged on purpose, which is why Tabler was the
choice. Both sets are stroked inside a 24-unit viewBox at a default stroke of 2,
so the two icon-stroke tokens (--stroke-icon 2, --stroke-icon-action 2.25)
carry over untouched and the button label still pulls its glyph weight up to
Semibold the same way. Alignment does not move either: it rests on Geist's cap
band, a fact about the font, not the icon set. What changes is the drawing:
Tabler's joins are rounder and several glyphs are simply shaped differently, so
expect a friendlier line and, in a handful of spots, a different picture for the
same idea.
A few icons have no same-name twin and were mapped to the closest Tabler glyph:
a trash can, a "more" affordance (Tabler draws it as three dots), a lightning
bolt, a globe (Tabler's is world), an info circle, a hamburger, and the
sort/select chevron pair. If you passed your own icon elements into a kit
component nothing here touches them; only the kit's built-in icons changed.
Icons render a touch larger than the raw Tabler glyph. Tabler draws inside a
wider margin than lucide did, so at the same box every icon read about a tenth
small, worst in menus. The tokens file now ships one rule, .tabler-icon { scale: var(--icon-optical-scale) }, with --icon-optical-scale: 1.1, so every Tabler
icon fills its box the way lucide's did. It also lifts the stroke by the same
tenth (a 2 reads as ~2.2). Set --icon-optical-scale: 1 in your own CSS to turn
it off; it is a normal token, so it survives the next pull.
New build failures
None. No new verify-ui rule ships with this entry.
One dependency note that is not a rule but can still break a build: the kit now
declares @tabler/icons-react instead of lucide-react. A fresh add installs
Tabler for you. If your OWN code imported lucide-react and was relying on the
kit to pull it in transitively, add it to your project directly; it is no longer
a kit dependency.
A selected row stops being blue
v2026-08-26.2
Moves on screen
A selected TableRow is neutral now, in both modes. It was
bg-accent-50 / dark:bg-accent-950, the quietest rung at each end of the
accent ramp. Quiet in lightness, not in chroma: the dark step is C 0.0753 at
the accent hue, and a select-all across a 39 row table painted a wall of navy.
That is emphasis, and a selection is a state. The fill is now a neutral alpha
tint, bg-neutral-900/5 in light and bg-neutral-100/[3.5%] in dark, at
C 0.0017 and C 0.0024, which cannot accumulate into brand colour at any row
count.
It is a tint rather than a scale step because no solid rung is free. bg-card
and bg-muted are adjacent neutral steps in both modes (L 0.9886 / 0.9725
light, 0.1651 / 0.2242 dark), and bg-muted is the header and the footer, so a
selected row landing on it reads as chrome. The new values clear all three
grounds by roughly the same 0.02 the old pair claimed: light lands at L 0.9531,
0.0355 under card and 0.0194 under muted; dark lands at L 0.2019, 0.0368 over
card and 0.0223 under muted. Both also clear the striped fill (L 0.9776
light, 0.1824 dark) by about 0.02, which the accent pair did not: accent-950
sat 0.0026 off the dark stripe and was told apart by hue alone, as was
accent-50 from muted in light at 0.0025.
Nothing else moves. The menu-row recipe (accent-100 / accent-800 +
text-accent) is untouched everywhere it belongs: Dropdown, Select, Command,
Combobox, Calendar, Tabs, the Nav pill, badges, ghost hovers. A popup row is
one transient highlight in a narrow column; a table row is full-width,
persistent, and multi-select, and that is the whole reason these two differ.
A count that sits on the title, not in it
v2026-08-26.1
Moves on screen
Nothing moves on its own. One addition to page-title-row:
New optional titleBadge prop. The slot for a count or a status pill
beside the page title, a Badge reading "39 items" or "Live". Until now the
only inline slot was subtitle, and a Badge passed there renders broken: that
wrapper is an inline <span> carrying truncate, and overflow does not apply
to a non-replaced inline box, so the pill comes out unclipped and hanging low
against the letterforms. A consumer hit exactly that with a row count, which
is what the prop exists to stop. titleBadge is a direct child of the title
cluster, centred on the title's cap band rather than seated on its baseline,
in a zero-height wrapper so it contributes nothing to the row's height: a page
carrying a count and a page without one put their titles on the same line.
Controls are untouched by this and still belong in actions.
The neighbour arrives already posed
v2026-08-24.5
Moves on screen
Nothing moves on its own. One addition to carousel (a kit extra — pull it
by name):
New export carouselTweenRestStyle from carousel-tween.ts. A
server-rendered coverflow paints its raw HTML — every slide full size —
before the tween's first frame, so the neighbours visibly snap down after
hydration. This returns the pose the tween will compute for a slide at a
given distance from centre, as an inline style for the markup, so the
pre-hydration HTML already matches and the handoff is invisible. Pass it the
same scaleMin/opacityMin given to the hook; hand-copying the numbers
instead is the drift this export exists to prevent. It fixes the pose, not
the track: when the start slide is dynamic or mid-list, Embla still paints
the strip left-aligned until init, and the right remedy there is fading the
whole carousel in on the api's arrival (the "Coverflow scale" docs example
does this). The hook's header comment now documents both remedies.
An arrow you can read over artwork
v2026-08-24.4
Moves on screen
Nothing moves on its own. The carousel arrows (CarouselPrevious /
CarouselNext, a kit extra) gain two opt-in props:
variant: "bare" (the default, today's naked chevron at half opacity)
or "chip", a filled circular button (bg-card, ring edge) at full opacity,
for arrows sitting over artwork, where a half-opacity chevron goes illegible.
tone: "default" (the theme's neutrals) or "accent", which reuses
Button's accent recipe. A bare accent arrow is an accent-coloured chevron; a
chip accent arrow is a solid accent fill with Button's exact rest and hover
pair, so it matches an accent button beside it.
The neighbour dissolves, not guillotined
v2026-08-24.3
Moves on screen
Nothing moves on its own. One addition to carousel (a kit extra):
New opt-in edgeFade prop on CarouselContent dissolves the slides at
the viewport's edges instead of clipping them on a hard line. It is for
peeking layouts (a fractional basis-*, a basis-auto strip, the coverflow
tween), where the neighbour slide is otherwise cut dead at the clip edge. The
gradient's stops trace a smoothstep, zero slope at both ends, so the fade
neither snaps off at the clip edge nor leaves a findable line where it lands
on full opacity. The width is a fixed pixel band, not a viewport percentage
(a percentage read as a kiss on one layout and swallowed the whole neighbour
on another): pass a number to tune it, true means 64px, and it should stay
comfortably inside the neighbour's visible peek. Dimming the neighbour is
the coverflow tween's job, and its opacityMin should stay near its default
when both run. It is position-driven, like the
coverflow tween: each edge fades only while content actually extends past it,
ramping in over one slide of travel, so at rest on the first slide the
leading edge stays a hard line (a loop carousel keeps both edges faded). A
mask rather than an overlay, so it works over any page background, and it
follows orientation. If you hand rolled this as a static mask utility on
viewportClassName, the prop replaces it and fixes that resting edge.
Coverflow, without the pop
v2026-08-24.2
Moves on screen
Nothing moves on its own. Two additions to carousel (a kit extra — pull it
by name):
New opt-in hook useCarouselTween (its own shipped file,
components/ui/carousel-tween.ts — a hook cannot leave the "use client"
module and still reach a Server Component as a value) gives a peek carousel
the coverflow
treatment: slides scale and fade with their live distance from centre, on a
smoothstep curve. The curve is the point — a piecewise-linear ramp puts a
velocity corner at each end and the arriving card visibly pops; smoothstep's
zero slope at the centre also absorbs Embla's settle jitter, so no dead band
is needed. Position-driven, so it tracks a drag 1:1 and inherits the
carousel's reduced-motion handling.
The duration default now documents its range. 14 is tuned for short
hops; over a ~500px slide it overshoots the snap and creeps back — a double
bounce. Measured: 18 lands with 0.7px overshoot and is still by ~430ms.
Long-slide carousels should pass 18-25.
A quiet delete is still a delete
v2026-08-24.1
A dialog breathes under its heading
v2026-08-20.6
Moves on screen
Dialog, AlertDialog and Sheet space their heading from the body on the
heading-md rung (24px), up from 16px. The title carries cap-trim, which
removes the font's top space, so a 16px gap measured from the cap edge read
tight against the body, most visibly when there was no description to soften it.
24px is the same rung PageHeader already gives a text-lg heading, so a modal
heading and a page heading now clear their content by the same amount. Drawer is
unchanged: its content spaces a short form, not a modal header.
The BreadcrumbBack chevron now recolors and nudges left on hover. It was meant to from the start; the hover state was wired to a group that was not there, so nothing fired. If you relied on the chevron staying still on hover, this is the change that starts it moving.
A <Card animateHeight> mounted on a non-card host no longer clips a focus
ring at rest. The shell used to lean on the Card base class to hide content
mid-animation, which also clipped a non-card host permanently. It now clips only
while a height animation is running. A plain Card is unaffected.
Code blocks stop where the copy stops
v2026-08-20.5
Moves on screen
Nothing, unless you reach for the new token. This is an addition.
New: --container-content and its max-w-content utility. It caps a block
that is READ, where max-w-measure caps a line of copy. Capping only the
paragraphs leaves the code blocks between them running to whatever width the
layout gives them, so the two edges keep no relationship at all: on this kit's
own docs a paragraph sat at 60ch while the code block under it ran to 976px,
half again the width of the sentence introducing it.
It resolves to var(--container-measure), so a code block and the copy above it
share one edge and retuning the measure moves both.
Put it on the blocks, not on the column that holds them. Some blocks in a document are read and some are examined, and only the first kind answers to a measure. A data table, a chart or a component specimen takes the width its content needs; capping those makes a table unreadable in service of a rule that was never about it. Capping the column was tried first and caught everything, and no column width is right for both a sentence and a twelve-column table.
A block a little wider than its copy was also tried and is the worst of the three: it reads as a near-miss rather than as hierarchy. 60ch is not a squeeze for code either, which is the usual objection to going flush: it renders 636px in Geist, about 81 monospace characters at 13px.
It is the one cap that carries no text-wrap pairing: text-wrap inherits, and
the copy inside a block already brings its own.
A table selection is a fill, and only a fill
v2026-08-20.4
Moves on screen
A selected TableRow is much quieter, and its cells stop turning blue.
The row used to stack two effects: an accent-100 fill (accent-700 in dark)
plus text-accent forced onto every td. Across a full-width table with
several rows selected that reads as a slab of brand colour rather than as a
selection, and the text recolor carried most of it, since accent-500 is C 0.2489
against the fill's 0.0342. It also applied unevenly: a cell that set its own
text-foreground kept it while its neighbours turned blue, so the row rendered
half-applied.
Selection is now a fill and nothing else, at the quietest rung on each end of
the ramp: bg-accent-50 in light, bg-accent-950 in dark. Both are the same
~0.02 lightness step off bg-card, which is the ground a Table paints for
itself. The dark step is not the accent-900 the menu-row recipe mirrors to:
its L 0.2407 sits 0.016 from bg-muted (0.2242), and bg-muted is the header
and footer, so a selected row would have read as chrome.
If you were relying on the blue text to find the selected row, pair the fill with the cue that should have been carrying it: a checked checkbox, or a count in the toolbar above the table.
A selected row in a striped table is now actually painted. The stripe and
the selection are both backgrounds on the same tr, and the stripe's selector
descends from the table element, so it scored (0,2,2) against the row's own
(0,2,0) and won the cascade on every even row. Selection was invisible on half
the table, and had been since striping shipped. The stripe now excludes a
selected row outright, so the two no longer compete. Only striped tables are
affected, and only their even rows.
Nothing else changed. The menu-row recipe (accent-100 / accent-900 +
text-accent) is untouched everywhere it belongs: Dropdown, Select, Command,
Combobox, Calendar. A popup row is one transient highlight in a narrow column;
a table row is full-width, persistent, and multi-select, and that is the whole
reason these two now differ.
The rules now point at themselves
v2026-08-20.3
New build failures
Nothing. No rule was added and no severity was promoted.
What arrives is a second skill and two procedure changes, aimed at one gap: the rules shipped, and nothing read them.
uimd-build installs alongside uimd-pull at
.claude/skills/uimd-build/SKILL.md. uimd-pull fires on "update the kit",
which happens a few times a year. uimd-build fires on building a page or a
component, changing spacing, or touching typography, which is every day, and it
routes to the Page rhythm, Spacing and layout, Type scale and Hard rules
sections of docs/ui-md-rules.md BEFORE class strings get written rather than
after. Reading them afterwards makes them a cleanup pass, and a cleanup pass
preserves pixel counts and loses relationships.
The upgrade procedure now wires the checker into package.json during the
pull, as "verify:ui", and calls it from the build or CI script. It used to
say "wire it into the build/CI if it is not there", which was a suggestion at
the end of a long checklist and was skipped. A checker that ships as a file and
is never run enforces nothing.
The AGENTS.md snippet now covers building, not just upgrading. For Codex,
Cursor, or anything else outside Claude Code, that is the only pointer at the
rules that exists, and it used to mention the kit only when installing it. The
kit still does not write to your instructions files.
Nothing here is required. If a project already runs the checker in CI and its agents already read the rules, this changes nothing for it.
AuthDialog stops reserving a line for an error
v2026-08-20.2
Moves on screen
AuthDialogis 32px shorter when there is no error, which is nearly every open. It used to hold an empty line under the description so an error could appear without moving the panel. That reserved line is gone: the description now sits 16px above the first provider button instead of 48px. On a short panel (two providers, no passkey) the hole between the description and the buttons is what this closes.- An error now grows the panel instead of filling a slot. The message region
animates open over 200ms, the same timing as a disclosure, and the panel grows
from its centre because it is centred vertically. Under
prefers-reduced-motion: reduceit appears with no transition. - The gap above the error message is 4px, not 12px. Neither the description nor the message is cap-trimmed, so both still supply their own half-leading; the 12px was the header's trim-compensated rung spilling down from the title and painted the pair about 8px loose. With an error showing, the panel is now 24px taller than its resting height rather than 32px.
Nothing about the public API changed, and the message is still announced. The
announcement moved to a permanently mounted screen-reader-only region whose text
tracks the error exactly, so clearing and re-setting the same message is still
announced as a repeat. data-slot="auth-dialog-error" still names the visible
message.
A completed step reads as success
v2026-08-20.1
Moves on screen
- A completed step in
Stepperis now green, not accent. The circle, its border and its check mark read--success, and the connector behind it fills green to match. The active step is unchanged: it stays accent with its ring, so the flow now separates three states by hue rather than two, and "you are here" is still the only thing wearing your brand colour. - The connector that lands on the active step blends from success to accent. Only that one. A line between two completed steps stays solid green, because a connector's two ends match the two circles it touches; gradienting every line that leaves a completed step would fade it to accent and land it on a green circle.
This is why --success reads "confirmation and completion" in the token table
rather than "confirmation" alone. If you had leaned on the accent-coloured
completed state to carry your brand through a checkout or onboarding flow, the
active step still does that, and StepperItem's state prop still overrides any
step's treatment by hand.
A new token ships with this: --success-foreground, the text and icon colour
that pairs with a --success fill. It mirrors --destructive-foreground and
defaults to the same near-white in both themes. Success was the only intent
colour shipping a fill with no partner, so anything of your own painting on green
had to borrow another token's foreground; it no longer does.
Latest: new component, `segmented-control`
v2026-08-18.8
Moves on screen
- Nothing.
segmented-controlis a new component and changes nothing you already have. It arrives withkit, since its only npm dependency ismotion, which is already in the universal set.
SegmentedControl is a compact in-place switch for one of a small set of
options, drawn as a pill group with a sliding thumb: a unit toggle, a view mode,
a billing period. It is a radiogroup, not a tablist, so reach for it when the
choice changes a value and for Tabs when it swaps a panel of content. Arrow
keys move the selection with wrap-around, an aria-label is required, and the
thumb is a single persistent element rather than a per-option shared layout, so
it cannot stretch across the track mid-slide. size reads the same control ramp
as Button and Tabs (28 / 32 / 40px of option, plus 4px of track each side);
tone="onMedia" swaps the palette for fixed black and white with a backdrop
blur, for sitting over a photo.
Stacked toasts read as a deck
v2026-08-18.7
Moves on screen
- A toast behind the newest one is now a clean blank card edge instead of cut-off text. Fire two toasts and the older one used to stay fully painted while being squashed to the new one's height, so its words and buttons showed through, clipped. It now fades its contents the way sonner's own deck does, and hovering the stack still expands it to a readable list.
- A toast whose panel had been opened no longer stands out below the deck. Opening a panel releases the card's height so it can grow; once that card was pushed to the back it kept the taller height and hung past the stack.
Both fixes have the same cause, worth knowing if you have restyled toasts:
toast.custom() bodies are always marked data-styled="false" by sonner, and
sonner's collapsed-deck CSS is split across that attribute. The rule that hides a
back toast's contents is gated on it; the rule that clamps a back toast's height
is not. The toast item now ships the missing half.
Titles wrap with `text-pretty`, not `text-balance`
v2026-08-18.6
Moves on screen
- Every left-aligned title in the kit now wraps with
text-prettyinstead oftext-balance, so a title that runs to two or more lines gets wider.CardTitle,DialogTitle,AlertDialogTitle,SheetTitle,DrawerTitle,ToastTitle,StepperTitle,PageHeader's heading andprose'sh1-h6are affected.text-wrap: balanceresolves to the narrowest width that still fits the text in the same number of lines, so a balanced two-line title stopped short of its container's right edge while the description under it ran all the way, and the two left-aligned blocks finished in different places. A single-line title does not move at all: neither property does anything until the text wraps. A wrapped one keeps the same line count in almost every case and simply fills the measure, which can move the break point by a word. - Titles longer than about six lines change behaviour more than that. Chrome
stops applying
balancepast that many lines, so those titles were already wrapping normally and now gain orphan protection they did not have. TooltipContentkeepstext-balance, and so do themax-w-measure-headingandmax-w-measure-heading-relaxedcaps thatLockupreads. Nothing about tooltips, lockups, or centred hero headings moves. The rule is the box, not the element: balance stays where the box hugs the text (w-fit) or the text is centred, because that is where there is no sibling edge to mismatch and the equalised lines read as a deliberate shape.
Card-header actions default to flush, not outdented
v2026-08-18.5
Additive
DashboardCardHeadertakesactionInset("icon" | "flush", defaultflush). The old blanket outdent conflated two things the slot carries: an icon button, whose box extends past its glyph and wants the glyph seated on the inset, and a badge/count/caption, whose visible edge is its box edge and must not be pulled off the inset. Splitting them makes the common drop-in case correct with no flag and asks the button case, the one that knows it has chrome to compensate, to declare it.
Moves on screen
- A
DashboardCardHeaderaction that is not an icon button moves 6px away from the right edge, onto the content inset. The header used to outdent every action by 6px, a correction meant only for an icon button's transparent chrome. A Badge, a count, or a caption passed asactionwas pulled 6px too close to the edge and read lopsided against the title. Those now sit flush. An icon Button or Kebab action needsactionInset="icon"added, or its glyph moves 6px inboard and reads adrift. The kit's own two examples that use an icon action (Compact Header, Tabbed Filter) carry the flag now.
Button icons now match Geist's weight
v2026-08-18.4
Worth knowing if you swap the typeface
- Icon alignment rests on a property of the font, and Geist happens to satisfy
it exactly. Icons sit in
items-centerrows, so flexbox centres them on the text's line box, whose centre is(ascent - descent)/2above the baseline and cannot be moved byline-height. The eye reads them against the cap band, centredcapHeight/2above the baseline. For Geist both are 355 per 1000 em, so icons land dead centre and no component needs a nudge. Point--font-sans-customat a face that misses this and every icon in the kit shifts at once with no class change, no CSS change and nothing failing.docs/ui-md-rules.mdhas the check and the one-token correction.
Additive
- Two new tokens,
--stroke-icon(2) and--stroke-icon-action(2.25). They ship frombaseinto:root, so your own unlayered:rootoverrides them. They exist because icon stroke is a property of the typeface, not of a component: if you swap Geist for a face with a different stem, measure its stem in em, multiply by 24, and set these two rather than touching call sites. The full derivation, including the counter ceiling that caps the stroke, is indocs/ui-md-rules.md.
Moves on screen
- Every button icon gets heavier and most get bigger. Button glyphs were
rendering at Lucide's stock stroke of 2, which measures out to Geist Regular,
while button labels are
font-semibold: two weight steps apart. Icons now read--stroke-icon-action(2.25), and the icon box steps 12 / 14 / 14 / 16px acrossxs/sm/default/lg, up from a near-flat 12 / 12 / 12 / 14. The stroke could not move on its own, because at a 12px box the tightest counter in the icon set (Trash2's lid) already had only 0.97px of clearance, so a heavier stroke with no extra size fills counters in rather than adding weight. - A button with an icon gets ~2px wider wherever its glyph grew, so a tight row of iconned buttons can rewrap. Icon-only buttons keep their box and only the glyph inside changes.
variant="link"is deliberately excluded. It is the one variant that setsfont-normal, so its glyph stays at stroke 2 and would otherwise have gone heavier than the word it sits in.Input,Badgeand prose icons are unchanged. They sit beside regular-weight text, where the stock stroke was already the right answer.- An iconned button's icon-side padding now scales with its size. It was a
flat 12px on the icon side at every size, which balanced only at
sm: atlgthe icon was crammed to the edge and the button read right-heavy, atxsthe asymmetry was reversed. The icon side is now 2px tighter than the label side at each size (6/10/14/18px acrossxs/sm/default/lg). Any button with a leading or trailing icon shifts its contents slightly, and a tight row can rewrap.
Cn() could not override the kit's own tokens
v2026-08-18.3
Additive
cn()is extended, because tailwind-merge cannot read a theme. It ships a fixed map of Tailwind's stock scales, so a utility built from a token the kit adds falls outside every class group it knows. Two such classes both survive the merge and land at equal specificity, so stylesheet order decides which one paints. That is not the call site, and it is not stable.Measured: 13 of 18 custom utilities failed to override. Two failed worse than failing.
text-displayandtext-jumbolook like colour names, so they were sorted intotext-color, andcn("text-muted-foreground", "text-display")dropped the colour and put nothing in its place.registry/lib/utils.tsnow teaches it the kit's container sizes, font sizes and control radii. Spacing is deliberately not taught: the named form (h-site-header) is banned because Tailwind inlines a spacing value at compile time, and the rung formh-(--spacing-site-header)is an arbitrary property that merged correctly all along. Teaching the frozen spelling would make a banned one behave.DashboardCardHeadertakesinset. A card header and the body under it share one left edge, so their side padding is one decision, and until now the header was pinned at 16px with no way to say otherwise except aclassNameoverride that moves one edge and leaves the other.default(16px) is the row tier and stays the default, so nothing moves.loose(24px) is the prose tier, for a card whose body is paragraphs rather than rows, where 16px reads thin once the card is wide. Set the body to match in the same edit. No third value, since a third inset is a third left edge to keep in step.
Moves on screen
- Anywhere a caller was already overriding one of 13 custom utilities, the
override now takes. Those call sites were shipping both classes and getting
whichever Tailwind emitted later, so some of them were painting the
component's default and will change. Check any use of
max-w-measure,max-w-chrome,text-display,text-jumbo, or therounded-action*ramp passed through aclassName. <Prose className="max-w-none">now actually removes the cap. That is the opt-out Prose has always documented, and it never worked, so a consumer relying on it has been rendering capped prose and will see it go full width.
New build failures
verify:registrygains one check, and it fails the build rather than warning, because it cannot fire on existing code: it only triggers when a token is added to--container-*,--text-*or--radius-*without being added to the list inregistry/lib/utils.ts. Nothing already in the kit trips it.
Smoothing belongs to the consumer, not to Sparkline
v2026-08-18.2
Additive
A band scale has no floor, so smooth the series, never the component. The chart rule already said how a baseline is chosen and stopped there. What it never said is that the band making a line honest will stretch any range to the full height, so it cannot tell a five-point drift from five points of sampling noise. A consumer hit both halves at once on newsletter tiles: an open rate spanning 5.7 points across twenty sends, and a click rate where one click moved the number by a point, drawn as a sawtooth that read as violent movement in a series that was flat.
The fix is a rolling mean in the consumer, over a window measured against its own data. That consumer counted direction changes to pick one: raw ran 12 on open rate and 10 on click rate, a 3-send window gave 8 and 6, a 4-send window 5 and 4, and a 5-send window went back up to 7 on click rate, because over-smoothing starts beating real turns against their neighbours.
Sparklinetakes nosmoothprop, and that is the rule's point. A drawing component that quietly averages what it was handed is the same lie as a band-scaled bar. The prop would also let one card print a number while the line beside it ends on another, which is exactly what smoothing costs: name it in the accessible label, and preferareaoverline, because a filled band reads as a level where a line invites you to take a point off it.
A rule for icons on text buttons
v2026-08-18.1
Additive
A rule for when a text button gets a leading icon. The kit documented the mechanics thoroughly (element vs bare component and the server/client boundary,
iconSize, the reduced padding on the icon side, the heavier in-button stroke) and never said when to reach for one. So consumers drifted, and the tell is a control row where the create button has a+, the destructive one has a trash can, and the two beside them have nothing, with no reason available for any of it.Two halves. A glyph is allowed only where it is the conventional symbol for that exact action, tested by hiding the label and asking whether the action can still be named:
+is add, a trash can is delete, and a check mark is approve and save and confirm and done, so it is decoration. And inside one button group it is all-or-nothing, because a single iconned button beside plain ones reads as the important one, which is an emphasis signal the variants already own.One override: a destructive action keeps its icon even in an otherwise plain group, since the button is red and the glyph is what keeps the meaning off colour alone (WCAG 1.4.1).
Icon-only buttons are untouched. There the glyph is the label and carries an
aria-label; this rule is about a glyph sitting next to a word.Deliberately prose, not a checker rule. Whether a glyph is conventional for an action is a judgment about meaning, so a checker would fail honest call sites and pass dishonest ones. The group half looks checkable and is not: a group is a visual cluster, not a DOM shape.
Two new rules
v2026-08-17.4
New build failures
rung-as-interior-padding(warning). A rhythm rung read into padding on opposing sides:p-,px-,py-, orpt-andpb-together in one class string. A rung measures the space between two blocks, and painting one as padding is correct where something sits between them that makesgapimpossible: aborder-tabove a footer row, or reopening the gap under a borderlessCardHeader. The kit ships both, and a single side is never flagged. Opposing sides are not a gap between anything, they are the block's own inset, and the ladder does not measure interiors.peeris 40px because that is what two peer blocks need between them, which says nothing about what a page shell should hold inside it.This rule exists because a consumer read "every vertical gap between blocks comes from a rung" as "every vertical space comes from a rung" and padded every page shell with
peerandsection. Each page opened about 120px taller than the rest of their site and nothing in the kit said it was wrong. Use the numbered spacing scale for an inset. Where the padding really is a gap a boundary stops you writing as agap, carry aui-md-allow-rung-paddingcomment and name the boundary.offsystem-color-on-kit-component(warning). A raw Tailwind hue (bg-pink-500,text-violet-400) in theclassNameof a kit component. It repaints the component from outside the kit's colour system, andcn()is tailwind-merge, so your class wins the base colour while every state the component paints for itself - hover, disabled, open, checked, the focus ring - keeps the colour it was built with. That is a control which looks right at rest and wrong the moment it is used, and it is also the one colour on the page that does not flip with the theme.This is not a ban on Tailwind hues. They are yours to use in your own markup, exactly as the colours page says, and are only flagged inside a kit component. Kit tokens are not flagged there either:
<TableCell className="text-foreground">and<CardHeader className="border-b border-border">are ordinary composition. Names the kit ships its own scale for (neutral,accent,destructive,success,warning,chart) are read out of the token file and resolve to the kit ramp, so they are never off-system. Where the component has avariant, that is the fix and it moves every state together. Where it does not, re-point the token for the scope (--accent,--muted,--cardon a wrapper), or colour a wrapper instead of the component. Deliberate exception:ui-md-allow-kit-recolor.The part names come from the installed kit's own
data-slotattributes rather than a list in the checker, so a component added or renamed upstream is covered without an edit here.carouselno longer readsautoHeightinCarouselItem. It was destructured and never used, which trips@typescript-eslint/no-unused-varsin a project that lints itscomponents/ui. Nothing about the rendered slide changes, and it was not fixable on your side: the nextadd -ooverwrites the file.
Carousel plugins
v2026-08-17.3
Moves on screen
carouselnow installsembla-carousel-autoplayandembla-carousel-fadealongsideembla-carousel-react. Both are first-party plugins from the same monorepo and maintainer as the engine you already had, pinned to the same 8.6.0, MIT, with zero runtime dependencies of their own and a peer onembla-carouselthat your install already satisfies. So the new surface is two packages and nothing transitive.They are declared rather than left to you because the alternative is a docs page whose examples do not run until you notice a missing import.
carouselalready lives inkit-extrasprecisely because it brings a runtime package; this does not change which bundle it is in.A vertical
Carousel's arrows start inside the viewport and move outside atsm, which is what the horizontal arrows already did. Vertical had only the outside position, at 48px above and below its own box, so any container that did not happen to leave 48px of room clipped them. Under anoverflow-hiddenancestor that produces a control you can see part of and cannot click, with nothing in the console. A vertical carousel with room to spare is unchanged atsmand up; below that its arrows now sit over the slide the same way the horizontal ones do on a phone.Carouseltakes anautoHeightprop. Off by default, so nothing moves unless you ask for it. On, the viewport sizes itself to whichever slide is in view, so slides of different heights each get their own instead of every one being stretched to the tallest.Horizontal only, and ignored when vertical. That is structural, not a missing feature. Embla measures its scroll bounds once at init. On the x-axis the viewport's height is not the scroll axis, so changing it afterwards costs nothing. On the y-axis it is the scroll axis: a viewport sized after init leaves Embla believing there is nothing to scroll, which disables both arrows and renders every slide stacked for a frame first. A vertical carousel needs a bounded height from CSS, before Embla measures.
CarouselContenttakes a newviewportClassNamefor exactly that, and the distinction matters:classNamelands on the flex track, so a height there does not bound the element that clips and every slide renders stacked.viewportClassName="aspect-square"bounds the viewport and derives the number from the carousel's own width. Same split, and the same reason, asScrollFade'sviewportClassName.Two things it gets right that a hand-rolled version usually does not. It measures the slide, never the viewport it resizes, because reading the element you are sizing feeds your own value back in. And it keeps a
ResizeObserveron the slides, because slides change size after mount constantly: an image with no intrinsic size finishing its load, a font swap, text reflowing. Without that the viewport keeps whatever it measured before the image landed. It runs in a layout effect, so the first height lands before paint rather than as a visible jump.That observer is the reason this is opt-in rather than the default: a gallery of one aspect ratio does not need it. Under
prefers-reduced-motionthe height is still set and only the 200ms transition drops, because here the height is structural, not decoration.A
CardHeaderfollowed straight by aCardFooterno longer collides with it. The header carries no bottom padding on purpose: whatever follows owns that gap, which is whyCardContentsupplies its own top padding. A footer cannot do the same. Its padding sits below its ownborder-t, and the missing space is above that line, so a card with a header and a bordered footer and nothing between them drew the rule across the description's descenders. Borderless footers had the same hole with no line to make it obvious: they were told to borrow 20px from the content above, and there was no content above.CardHeadernow supplies its own 20px when aCardFooteris its next sibling. Any card built that way grows 20px taller. A card with aCardContentbetween the two is unchanged.Every cap-trimmed title sits 4px further from its description.
--spacing-rhythm-heading-pairgoes 8px to 12px, which movesPageHeaderand theHeaderpart ofDialog,AlertDialog,SheetandDrawer. Nothing else reads that rung.8px was derived to paint what the plain 4px
pairrung paints on an untrimmed box, and that arithmetic was right as far as it went. What it missed:cap-trimputs the title's bottom edge at the baseline, and a descender still paints below the baseline. So a title with apor ajin it spends 0.254em of the gap on ink, and a title without one does not. "Delete this project?" read cramped while "Terms of service" read fine, at the same token.It gets worse as the title gets bigger, because that 0.254em is a fraction of the font size: about 3px under a 12px heading and about 7px under a 28px one. 12px covers the descender case at every size the kit ships and leaves the clean case a pair rather than a separation.
If you override this token, you are unaffected. If you set a heading group by hand on the plain
pairrung, you are also unaffected, andtrim-rung-mismatchalready warns about that.A
DialogTitle,AlertDialogTitle,SheetTitleorDrawerTitlethat wraps to two lines is now readable. All four carriedleading-noneandtext-balance, which contradict each other:text-balanceonly does anything when the text wraps, so a title carrying it expects two lines, and at a line-height of 1.0 the second line's ascenders collide with the first line's descenders.CardTitlehad already reached the other answer and says so in a comment. All four now readleading-snug, the same asCardTitle.A single-line title does not move.
cap-trimcollapses the box to cap-to-baseline whatever the line-height is, so the change reaches only a title long enough to wrap. If yours wrap, those overlays get taller by the added leading.A
MetricCardbadge no longer pushes the line under it down. The value row is baseline-aligned, so its height ismax(above-baseline) + max(below-baseline). Cap-trimming the value took its below-baseline extent to zero, which is the point, but it also handed that edge to whatever else sat in the row: asmBadge hangs about 5.7px below the baseline, so a card with a badge grew that much taller than one without and its goal line sat lower. In a row of cards that meant second lines at different heights.The badge now renders in a zero-height wrapper, so it cannot set the row's height. It overflows the row instead, centred on the number's cap box, which is where a pill beside a number wants to sit. Nothing clips: the overflow is about 3px each way inside 20px of padding.
The slot this uses (
MetricCardValue's children) is therefore for a short inline annotation, not a control. Something 32px tall would now overflow 16px each way and reach the line below. If you were passing a control there, move it under the value block.MetricCardpins its trend chart to the bottom of the card. Metric cards live in a grid, a grid row stretches every card to the tallest, and the text block is the part that varies: a card with adeltaline is one line taller than one without. Packed from the top, that difference fell as dead space under the chart, so three cards in a row showed three charts at three different heights and the one with the least text got the biggest hole beneath it. The slack now sits above the chart instead, where it reads as breathing room, and a row of cards shares one chart baseline to scan across.A card that is not stretched has no free space to absorb, so a standalone
MetricCardkeeps its natural height and looks the same.New rung
--spacing-rhythm-lines(12px), andMetricCardValuereads it. The ladder jumped from 8px (bind,heading-pair,label,help) straight to 16px (cluster), and a stat block sits between them.linesis for three or more short lines that read as one unit: a value and the lines that qualify it, a label over two facts.pairis the two-line case;clusteris where they stop being one unit.It assumes the lines are cap-trimmed. An untrimmed stack already gets this much air from its own dead space, which is exactly the trap it came out of:
MetricCardValueasked for 4px and painted 15.4px between its value and its goal and 14.1px between goal and delta. Not just far more than the number it set, but uneven, because a 20px value sheds more dead space than the 13px lines under it. Its three lines are now trimmed and the gaps paint an even 12px. If you useMetricCardValue, expect the block to tighten by about 3px and stop drifting between its own rows.Your own headings are no longer cap-trimmed, and go back to how they looked before today's earlier pull. The trim shipped this morning as a blanket
h1, h2, h3, h4, h5, h6rule in@layer base, which reached every heading in your app. It is now an opt-incap-trimutility that the kit applies inside the components that also own the gap beside the heading:PageHeader, theTitlepart ofDialog,AlertDialog,SheetandDrawer, and headings insideprose-flow.Lockupis deliberately not among them: its title-to-description step is four frozen per-size classes rather than a rung, so it renders exactly as it did before this morning. Those render exactly as they did after this morning's pull. Everything else, including every heading you wrote yourself, reverts.Why it was pulled back. A blanket trim changes every heading at once, while the gap that compensates for it can only change one container at a time. The two sets never line up. Trimmed headings sat on gaps nobody had adjusted, and adjusted gaps landed next to text that was never trimmed. Neither failure is visible in code review, and finding them one at a time is unbounded work. Tying the trim to the component that owns the gap makes it one decision in one file, and leaves your own markup alone.
You can still opt any heading in: add
cap-trim, and set its container's gap to a rung chosen for a trimmed box (see the next item).--spacing-rhythm-pairgoes back to 4px, and the compensated gap gets its own rung. This morning's pull raisedpairfrom 4px to 8px. That was wrong, becausepairwas doing two jobs: binding a trimmed heading to its description inPageHeader, and binding an untrimmed label to its value everywhere else. No single number is right for both. The result was 4px of unwanted air under every metric value, swatch caption, form help line and card title in the system, none of which had a heading anywhere near them.Token This morning Now --spacing-rhythm-pair8px 4px (back to yesterday) --spacing-rhythm-heading-pairdid not exist 8px Use
pairfor an untrimmed tight pair andheading-pairwhere the title carriescap-trim. The two numbers differ so the two gaps paint the same. The other three rungs re-tuned this morning (heading-sm20px,heading-md24px,heading-lg32px) are unchanged and still correct: every one of their uses sits under a trimmed heading.If you overrode
--spacing-rhythm-pairin your own CSS to undo this morning's change, delete the override.DialogHeader,AlertDialogHeader,SheetHeaderandDrawerHeaderread a rung instead of a frozengap-2. Same 8px on screen. It is now overridable, which it was not before: agap-2class is inlined at compile time and no token could reach it.
New build failures
--spacing-rhythm-heading-pairis now--spacing-rhythm-trim-pair, and theStackgap value"heading-pair"is now"trim-pair". The old names are gone. There is no alias and no deprecation window: avar()on the old token resolves to nothing, and<Stack gap="heading-pair">stops type-checking. Nothing moves on screen. The value is the same 12px it was, so a project that only renames its references renders exactly what it rendered before.The name was wrong, and it was wrong in a way that cost a real call site. The rung means "the tight
pairrelationship rendered on a cap-trimmed box", and nothing about it needs a heading.MetricCardValuewanted precisely that relationship and could not reach for it, because a token withheadingin the name reads as off limits next to a number. A name that turns people away from the right rung is worse than a long one.Renaming a shipped token is normally not worth the churn. This one is cheap today and will not be tomorrow: it landed earlier in this same day's entries, so the only projects it can reach are ones that pulled within hours of it going up. That window is why the rename is happening now rather than living with the wrong name forever.
Two rungs deliberately keep their names:
--spacing-rhythm-heading-sm,-mdand-lgare the gap below a heading group, ramped by heading size, soheadingis accurate there and they are untouched.Fix it with a find and replace across your project, both spellings, then run
node scripts/verify-ui.mjs. If you overrode--spacing-rhythm-heading-pairin your own CSS, rename your override too: an override under the old name is silently dead, since nothing reads it any more.New rule
trim-rung-mismatch, severitywarn. Flags a gap rung that disagrees with whether its heading is trimmed: the plain--spacing-rhythm-pairsitting beside acap-trimheading, or--spacing-rhythm-heading-pairin a file that trims nothing at all. Both are invisible in review, because the class is real, the token exists, and only the pixels are wrong.The fix is usually not to swap the token but to use the component that encodes the pairing:
PageHeader,DialogHeader,SheetHeader,DrawerHeader,AlertDialogHeader. A component cannot pick the wrong rung.Where the rungs themselves are the subject (a spacing specimen, a demo showing both side by side), opt the line out with a
ui-md-allow-trim-rungcomment.It will not fail your build yet. If your build script runs
verify-ui.mjs --strict, it will:--strictpromotes every warning to an error, so check your own script before assuming a new warning is safe.
Later
v2026-08-17.2
Moves on screen
Four rungs got bigger, because cap-trim exposed what they were really worth. Take this pull together with the trim entry below it: that change tightened every gap next to a heading, and this one re-tunes the gaps that were tuned by eye before the trim.
Token Was Now --spacing-rhythm-pair4px 8px --spacing-rhythm-heading-sm16px 20px --spacing-rhythm-heading-md16px 24px --spacing-rhythm-heading-lg24px 32px Why they were wrong: an untrimmed heading box carried dead space proportional to its own font-size, roughly
0.31embelow the baseline at--leading-snug. That is about 9px under a 28px title, 6px under an 18px one, 4px under a 12px label. So one flat number rendered as three different gaps, wider under the bigger title, which is exactly what made it look right. None of that was in a token. Trimming the box deleted the subsidy and left the flat number exposed, so these four now state outright what they used to get for free.heading-smandheading-mdwere both 16px and could not stay that way: their titles are 14px and 18px, so they lost different amounts.The page-scale rungs (
peer,region,section) were left alone even though the trim does reach them: aStackaround aPageHeaderputs one directly above a trimmed heading. A 40pxpeerabove algPageHeader used to paint about 47.5px and now paints 40. They were not re-tuned because the same rung also sits between two ordinary blocks where nothing is trimmed and the value is already right, and the difference is 10-19% on gaps big enough to absorb it. If one reads tight on your page, raise it there.No other rung moved.
cluster,field,label,help,peer,regionandsectionnever sit next to a heading, so the trim never reached them.Where you will see it: any title over its description gains 4px (
PageHeader,CardTitleoverCardDescription, and anywhere you usedpairyourself), and a heading group clears its content by 4-8px more. If you had already re-tuned these tokens in your own CSS to recover from the trim, your values still win, and this is the moment to delete them and check the defaults instead.One rough edge, stated rather than hidden:
pis not trimmed, only headings are. So a rung sitting under a description still paints about 6px looser than the same rung under a bare heading. Both are consistent per shape; they are not consistent with each other. Trimming paragraphs too would fix it and would move text inside every padded overlay, so it is not in this release. Padding is not a gap: no token re-tune reaches it, because those numbers are shared with icons, inputs and rows that have no text box to trim.prose-flow's four heading gaps gained 8px each. Its other two did not move. Same correction as the rungs above, applied to the long-form utility. It owns top-margin on the following element, so these four each sit next to a heading and each lost that heading's dead space: roughly 6px next to an h3, 9px next to an h1.Token Was Now --rhythm-above-h328px 36px --rhythm-above-h248px 56px --rhythm-above-h164px 72px --rhythm-below-h132px 40px --rhythm-base(block to block, nothing trimmed on either side) and--rhythm-figure(atableorpre, which has no text box to trim) render exactly as they always did and were left alone. If you re-tune any of this yourself, that is the rule to copy: compensate only where a trimmed box is actually adjacent. Raising a gap that never lost anything just makes it loose.Two things this changes that you may have relied on.
--rhythm-below-h1is no longer literally half of--rhythm-above-h1; that was only ever true of the token values, never of the painted gaps, because each carried a different amount of dead space. And anh1followed directly by anh2lost dead space on both sides, so it stays about 8px tighter than before. That pairing has no content between the two headings and is rare enough that it did not earn a token of its own.Watch your gaps against the line-height now, not just against each other. Untrimmed boxes supplied half-leading on both sides of every block, which quietly kept the gap between two paragraphs larger than the gap between two lines inside one. Trim removes that guarantee. A 16px gap between blocks of 24px-leading text now reads as no break at all, so if you have hand-set a gap between text blocks anywhere, compare it to the leading before deciding it is still right.
New build failures
heading-hand-tuned-margin(warning, not yet an error). Fails on a numberedmt-*,mb-*,my-*orspace-y-*in theclassNameof anh1-h6or a kit*Titlepart.This exists because of the trap in the change above. Gaps under headings got tighter, and the tempting fix is a margin on the heading. That margin is frozen at compile time and sits outside the ladder, where no token, theme, or override can reach it. The fix is the rung: put the heading and its description in a group on
--spacing-rhythm-pairand let the group clear its content on--spacing-rhythm-heading-sm|md|lg, or usePageHeader, which encodes both.A negative margin is the one to look for hardest.
-mt-1on a heading is almost always a nudge that was compensating for the untrimmed box, so it now double-counts: delete it rather than re-tune it.Two things it deliberately leaves alone.
mt-0is a reset of an inherited prose margin and is correct. So is the rung-reading formmb-(--spacing-rhythm-heading-md), which follows an override the way a frozen number cannot. A line that needs the margin anyway can carry aui-md-allow-heading-margincomment.
Changes
v2026-08-17.1
Additive
Superseded, see above. The kit no longer asserts this globally; the trim travels with the components that own the gap beside the heading.
The kit now asserts that headings are cap-trimmed without owning how they look. Size, face, weight, tracking and any
.h*-displayrecipes stay yours; the trim is the one thing hoisted here, because it is the only part that decides whether a spacing token renders honestly. A project that had been applying the trim in its own CSS can delete that rule after this pull.
Moves on screen
Superseded the same day by the entry above: this blanket trim was pulled back to an opt-in
cap-trimutility. If you are reading both entries in one pull, follow the newer one and skip the opt-out recipe below, which no longer applies because there is nothing global to opt out of. Kept for the record.Every heading is cap-trimmed, so every gap touching a heading tightens.
h1-h6now carrytext-box-trim: trim-both; text-box-edge: cap alphabeticin@layer base. A heading's line box used to be taller than its letters, so every gap above or below one rendered looser than the token that set it, and the token was never the number you saw.How much depends on your heading face and size. Measured on the face that found this (ascender 0.975em, cap 0.721em) at 28px with
--leading-snug: 8.8px of dead space above the cap and the same below the baseline. So--spacing-rhythm-heading-lg(24px) painted 32.8px. The offset is fixed, so the smaller the gap the more it lied: that 24px was off by 37%, a 64px section pad by 14%. Expect small gaps to tighten most.Two knock-ons worth looking for. Headings sitting in a flex row with
items-centernext to an icon, badge, or arrow will re-center, because the box they are centered against just changed height;items-baselineis the fix and may already be there. And any nudge someone added to compensate for the untrimmed box is now double-counting: delete it rather than re-tune it.Descenders hang into the gap below a heading now. That is what
alphabeticmeans and it is intended, not a clipping bug.Firefox does not support this yet (Chrome/Edge 133+, Safari 18.2+; Firefox has it behind
layout.css.text-box.enabledin Nightly as of 2026-08). An unsupporting browser ignores both properties and renders the untrimmed boxes it rendered before, so this degrades to the old look rather than to a broken one. Shipped with that known, because the alternative is gap tokens that never mean what they say in any browser.To opt out, re-declare it in your own CSS below the kit import:
h1, h2, h3, h4, h5, h6 { text-box-trim: none; }No longer needed. The blanket rule is gone; see the newer entry.
Later
v2026-08-14.2
Additive
SiteHeaderis finally responsive, and it needed no new prop. The bar height, the sticky offset and the scroll margin are now read ash-(--spacing-site-header),top-(--spacing-site-header)andscroll-mt-(--spacing-site-header)instead of theh-site-headerfamily.This was a real bug, not a tidy-up. Tailwind v4 inlines a named spacing token when it compiles, so
h-site-headershipped asheight: 4remwith novar()in it and your override of--spacing-site-headercould never reach it. The only way out was restating the compiled utility in an unlayered block, which is what at least one site was doing. The paren form compiles toheight: var(--spacing-site-header)and stays live.A compact mobile bar and a taller desktop one is now two lines of your own CSS, and the sticky offsets, scroll margins and hero scrim all follow from that one number:
:root { --spacing-site-header: 3.5rem } @media (width >= 48rem) { :root { --spacing-site-header: 5rem } }If you were patching
.h-site-headeror.top-site-headerdirectly, delete that block and set the token instead.Drawerhas a second layout for application panels.<DrawerContent layout="panel">turns the drawer into a cart, an inspector or a filter rail:DrawerHeaderbecomes a fixed 64px bar with the title and its metadata left, an inline close right, and a hairline running wall to wall; the newDrawerBodyscrolls under it;DrawerFooterpins to the bottom with its own hairline. The popup owns no padding in this layout, so each region pads its own contents and the rules reach both edges.The default
proselayout is untouched, so nothing changes unless you ask forpanel. If you were building this shape by hand withp-0 gap-0 showCloseButton={false}plus your own<header>and an absolutely positioned hairline, that all comes out; the layout is one prop onDrawerContentand the parts read it.DrawerHeadertakes acloseslot underlayout="panel". Pass your own control (<DrawerClose render={<MyButton />} />) when the bar's close needs a different accessible name, icon, or an analytics attribute. Omit it and you get the stock X.DrawerBodyis new. Use it for the scrolling middle of any drawer. It carries themin-h-0thatflex-1 overflow-y-autoneeds to actually scroll instead of growing the popup past the bottom of the screen.
Moves on screen
The site header is 80px, up from 64px.
--spacing-site-headermoved from4remto5rem. EverySiteHeaderon your site gets 16px taller, and anything you pinned to it (a sticky sub-nav, an anchor's scroll margin) moves with it.If 80px is wrong for you, it is now genuinely one line to change, which is the other half of this release. See below.
The hero scrim tracks the bar instead of being its own length.
--spacing-site-header-scrimis nowcalc(var(--spacing-site-header) * 2). It was8rem, which was exactly twice the old bar and stopped being twice anything the moment the bar moved. Only affectssurface="hero".
Changes
v2026-08-14.1
Also in this release
Pull the extras you already have.
kitdoes not list the eightkit-extrascomponents, soadd -o kit.jsonhas never updated them. If you installed one of these, it has been frozen at the release you installed it on, through every pull since:calendar carousel code-block combobox command rich-text-editor sonner toastIt type checks and it renders. It is just an older release of the same component sitting beside newer ones, which shows up as a fix that looks half-applied rather than as a missed pull.
node scripts/verify-ui.mjs --driftis what catches it: the file differs from what is published, so it gets listed. Read that listing carefully, because it uses one label,kit-file-differs, for two opposite problems. On akitcomponent it usually means you edited the file and the pull is about to revert it. On one of the eight above it usually means the file is just behind, and the remedy is to pull it by name.Add the ones you have to a single call, by name. This installs nothing new, and their packages are already in your
package.json:npx shadcn@latest add -o https://ui.mattdowney.com/r/command.json \ https://ui.mattdowney.com/r/combobox.jsondocs/ui-md-upgrade.mdstep 2 now carries this, so it happens on every pull from here. Do not install one you do not already have: each forces a third-party runtime package.verify-uinow says when it could not reach the registry. It fetcheskit.jsonandkit-extras.jsonon every run, which is the only way it can tell an installed kit file apart from your own code. That fetch failing has always been safe: everything under aui/directory is treated as kit and skipped, so a network blip can never fail your build. It was also silent, and the counts read identically either way.That matters most if you have wired this into
npm run build, because a clean offline run checked strictly less than a clean online one at exactly the moment it decides whether a deploy ships. It now prints a line saying so. Nothing about the exit code changed.Badgegrew adisplaysize, for a featured stat in a chip on a marketing surface: a subscriber count in a hero, a founding year beside a wordmark.<Badge size="display" icon={<Star />}>12,480 subscribers</Badge>It is a different job, not the next step of the annotation ramp: 16px type, deliberately skipping body size, with padding and icon scaled to match. The annotation ceiling still holds everywhere a badge annotates something (
lg, 13px, remains the largest of those). If you were faking this size throughclassName, thesize-faked-with-classnamelisting goes away when you move to the prop.ProgressRingtakes aweight. The stroke has always been an eighth of the diameter, which is tuned to the 16-48px rings the kit ships; perceived weight grows with area, so a ring sized well past that reads the same ratio as a donut.weight="thin"halves the stroke to a sixteenth:<ProgressRing value={72} weight="thin" className="size-20">72%</ProgressRing>Use it when you size the ring past the shipped three with a
className, instead of forking the component to redraw the arc. Rings without the prop are unchanged.NavItemtakes acount, the same counter chip aTabsTriggercarries, and the same recipe: solid accent fill on the active item, neutral tint on the rest,tabular-numsso a live number does not jitter.<NavItem href="/inbox" icon={<Inbox />} count={12} active>Inbox</NavItem>Horizontal, the chip sits inline after the label. Vertical, it is pushed to the trailing edge so a column of numbers reads as a column, which needs the nav to have a width; an item without a
countleaves the slot empty rather than reflowing its label. Cap large counts at"99+"yourself, and add anaria-labelwhere a bare number needs context. Items without the prop render exactly as before.ProgressRing, the same progress as a ring, shipped from theprogressitem beside the bar.<ProgressRing value={72} size="lg" aria-label="Storage used">72%</ProgressRing>For the places a full-width bar does not fit: a row, a card corner, beside a label. Three sizes (16 / 24 / 48px), and the stroke is an eighth of the diameter at each, so all three read as one ring rather than three weights, and that holds if you size it past those three yourself. Children sit in the middle, out of the flow, so the box stays the size it was asked for, and
lgis sized to hold a two-digit percentage without it touching the arc.It never spins. A ring is a quantity; a spinning ring is a wait, and the two are only a shape apart. With no
valueit draws an empty track and holds still. The bar keeps its indeterminate state.Pagination, a pager for a table, a card grid, or any list.<Pagination page={page} pageCount={12} onPageChange={setPage} total={240} pageSize={20} pageSizeOptions={[20, 50, 100]} onPageSizeChange={setPageSize} />Give it
totalandpageSizeand it renders the "1-20 of 240" count; give itpageSizeOptionsand it renders the rows-per-page select. With neither, it is only the pager, and it centers under whatever it belongs to rather than sitting against one edge.It works from a Server Component. Pass
renderPage={(page) => <Link href={?page=${page}} />}and the pages draw as links instead of buttons, which is the one shape a server page can do: it can build a link, but it cannot hand a click handler to a button. The component carries no"use client"so that stays true.The window of page numbers is always the same length. The usual pager drops its ellipsis near either end and renders fewer controls there, so the row changes width as you page through it and everything beside it shifts.
MetricCard, the dashboard card for one number: title, value, goal, a delta badge, and the metric's recent trend, in one component.<MetricCard title="Subscribers" value="183" goal="Goal: 262" delta={{ value: 79 }} trend={{ data: subscribers, type: "bar" }} />It is a composition, not a new surface.
Card,DashboardCardHeader,MetricCardValue, andStackall still ship on their own, and what this adds is the rhythm, which is the part that was being retyped and quietly getting different at every call site: the goal line and anything you pass as children bind to the value at 8px, and the chart sits under that whole block at 16px as a peer. When your layout differs, reach past it to those parts. That is what they are for, and it is not a fork.Sparklineships from the same file, and draws the chart. Three shapes on onetypeprop:barfor a count per period,linefor a trend,areafor volume under a trend. Three heights onsize(24 / 40 / 64), and the width always fills its container.MetricCarddraws it for you throughtrend, but it is reachable on its own for a table row or a list item, where a card would be the wrong box entirely.A
barchart is drawn, not laid out. The bars were a row of CSS boxes, and a row of boxes cannot be evenly spaced at an arbitrary width: thirty columns in a 203px card is a 6.77px pitch, and a browser snaps a box to whole device pixels, so the gaps land 3px and 4px apart in whatever pattern the fraction falls in. The bars measure identical and the row still reads uneven, because the eye takes the whitespace as part of the rhythm. They are<rect>s in an SVG now, where geometry is antialiased instead of snapped, so every bar and every gap carries the same ink at any container width.The chart fills its container. The bar row used to stop at a maximum width per point and center itself, so a wide card drew a short chart adrift in the middle of it. A bar is 60% of its column at every size instead, which means a wide card gets wider bars rather than the same thin ones in bigger gutters.
It renders on the server with no JavaScript and no third-party package, so a wall of metric cards costs nothing at runtime. Every mark paints
currentColoroff atext-accentroot, so it follows your accent automatically and one class retargets the whole chart:<Sparkline data={churn} className="text-destructive-500" />.The scale is not the same for every shape, on purpose.
barmeasures from zero, because a bar's length is the value and a band-scaled bar claims a 98 is a fraction of a 100.lineandareameasure the min-to-max band, because a zero-based line flattens any series whose values sit far from zero. A series that crosses zero belongs inline; bars clamp negatives to the baseline.A bar is always half its column, so bar and gap hold the same ratio at any card width and any number of points. An empty
dataarray still renders at full height with a hairline on the baseline, so a card does not jump when its numbers arrive.
Moves on screen
Dark mode: every accent-tinted selected state got one step lighter, and its label one step brighter. The pair went from
accent-900undertext-accenttoaccent-800undertext-accent-400. Light mode is unchanged.It was backwards. On the old pair the selected label sat at 3.22:1 while the unselected labels beside it sat at 7.32:1, so the item you had chosen was the hardest one in the row to read, and it failed AA while its neighbours passed. Tabs is where it shows worst, because the active tab sets its colour on the button and lets the label inherit.
You get this on: Tabs, Nav (and the mobile site nav), Badge, the tinted Buttons (
outline,soft,ghost) and their count chip, Select, Command, Combobox, DropdownMenu (every row state, icons included), Toast's action rows, and Lockup's tinted tone. Nothing to change. If you re-declared--color-accent-*you keep your own hues, since these are scale steps.Three of those are
kit-extrasitems, soadd -o kit.jsonwill not bring them: Command, Combobox, and Toast. If you have them installed, pull them by name in the same step or they keep the old colours while everything around them changes, which reads as the fix being half-done. See "Pull the extras you already have" below.Dark mode: the primary Button rests a step down, and its hover reverses.
defaultnow rests onaccent-600and hovers up toaccent-500; light still rests on 500 and hovers to 600.A saturated accent-500 plate is the loudest thing on a near-black page. The step down quiets it against the page (3.94:1 to 2.78:1) and at the same time raises its label (5.0:1 to 7.07:1). The hover had to flip with it: "one stop deeper" only means more presence when the page is light, so on dark the old hover walked the fill toward the background and the button receded under the pointer.
destructiveis unchanged.Pagination's rows-per-page popup now opens flush with its left edge instead of centered on it. The popup is sized to its trigger with a 128px floor, and a trigger reading "10 per page" is under that floor, so the popup came out wider than the trigger and centering hung the extra off both sides. The select sits at the left edge of the pager row, so that left overhang broke the edge the count and the table above it line up on. Nothing to change; if you were passingalignyourself, you still win.
New build failures
bare-element-inherited-property, a warning. It does not fail your build today. It will be promoted to an error in a later release, and that promotion gets its own line here.It flags a rule in your own CSS whose selector names a bare element with no class, id, or attribute anywhere in it, and which sets a property that inherits:
color,font-family,font-size,font-weight,line-height,letter-spacing,text-transform./* flagged */ span { color: var(--color-muted-foreground); } /* fine: scoped, so it cannot cross a component boundary */ .prose span { color: var(--color-muted-foreground); }An inherited value has nothing to defend itself with. Kit components set colour and type on their root and let the text inside take it, so any rule that names the inner element directly wins, no matter how specific the component's own selector was. A bare
span { color }silently repaints the label of every component whose label is a<span>.The case this came from cost a long debugging session and looked like a bad port. It was not one. Tabs sets
data-[active]:text-accenton the button and lets the label inherit, so the button really was accent while the word on it stayed grey. The component file matched the registry byte for byte, the token resolved identically, SSR emitteddata-activeon the right element, and the variant compiled into the bundle at winning specificity. Every static check passed. It was visible only by reading computed styles on the live DOM, which is why it is worth a rule.Only the generic wrappers are flagged:
span,div,li,a,button,label.p,h1-h6andblockquoteare left alone, since those carry meaning and components rarely emit them structurally, so styling them is describing your own prose rather than reaching into a component. Minified and compiled CSS is skipped. A rule that is deliberately global opts out with aui-md-allow-bare-elementcomment on it or in the three lines above it.
Changes
v2026-08-13.1
Also
- The upgrade procedure now ships to
docs/ui-md-upgrade.md: the whole sequence (drift check before the pull, pull, changelog diff, the stale-token and import-order traps, the rung conversion, verification) as one tool-neutral checklist any agent or person can run. Point Codex, Cursor, or anyAGENTS.md-reading tool at it with one line, which the file's last section spells out. Claude Code users get it wired as a command: the kit also ships a thin skill to.claude/skills/uimd-pull/SKILL.md, so/uimd-pullreads and runs the same file. Not using Claude Code? Delete the skill file; the procedure is not in it. verify-ui.mjsgained rule severities and a--strictflag, which is what lets a new rule warn instead of breaking your build on the day it ships.- Fixed: the checker's class-string scanner did not understand regex literals, so a regex containing an odd number of backticks opened a string it never closed and left the rest of that file unchecked. Five rules shared the blind spot, silently. If a file of yours was quietly exempt, it is not any more, and you may see violations appear in code you did not touch.
verify-ui.mjsgained--all, which lifts the 20-findings-per-rule display cap; the first consumer conversion had to copy the script out of the kit just to see its whole list. The upgrade procedure also now scopes step 5 to the errors a pull introduced: a pre-existing backlog is its own piece of work, not an upgrade's cost.- Fixed: the checker now skips every hidden directory (
.design-sync,.cursor, and friends), not just a named list. The first consumer run linted a design tool's generated previews as if the project had written them; a generator's output answers to the generator, not the style rules. - Fixed: the rules file said
--spacing-rhythm-heading-smis 8px. It ships 16px. - The rules table in the Checking your work section was two rules out of date.
Moves on screen
SiteFooterrenders 8px airier. The footer shell's gap between rows, and the vertical gap in its column grid, were a frozen 32px. Both now read thepeerrung (40px), so they follow the ladder and answer to a token override.SiteFooterRow'sdividerpadding moved with them, since that padding and the shell gap are one measurement written twice.
New build failures
kit-typeface-unwired, as a warning. The kit now installs thegeistpackage: Geist, stylistic sets and all, is the system's typeface, and the token file's letterform tuning is pointed at it. A font cannot wire itself, so this warning fires when a project has neither the two-line Geist wiring in its root layout (import { GeistSans } from "geist/font/sans", thenGeistSans.variableon the<html>className) nor a--font-sans-customoverride. Un-wired, every page silently renders system-ui. Projects with their own brand face via--font-sans-customare already clear and change nothing.family-without-feature-settings, as a warning. A CSS rule that sets a non-kitfont-familymust setfont-feature-settingsin the same block. The kit's Geist-tuned list inherits from body into every element, and stylistic set numbers are per-family: the recorded case was a display face whose same-numbered set substituted a wide space and tripled every word gap, while every static check passed.normalis the safe reset; a face verified to want the inherited list opts out with aui-md-allow-feature-inheritcomment. Minified/compiled CSS is skipped.frozen-page-scale-gap, as a warning. Flags a numbered vertical gap of 32px or more:space-y-8,gap-y-10,mt-12, or a baregap-*on a non-wrappingflex-col. Tailwind inlines that value when it compiles, so the number is frozen and no token override can reach it afterwards. Convert to a rung (<Stack gap="peer">, or the class formgap-(--spacing-rhythm-peer)). Below 32px nothing is flagged. Where the pixel count is the subject, opt the line out with aui-md-allow-frozen-gapcomment. This will become an error in a later release.