All notable changes to @vaneui/ui are recorded here.
The format follows Keep a Changelog, and the package follows Semantic Versioning: patch for fixes, minor for additive props and components, major for anything that changes an existing prop name, default, or rendered element. Releases ship when work is ready rather than on a fixed calendar.
1.4.3
2026-09-30
Fixes from a review of the components composed into real screens (dashboards, forms, landing pages, articles, overlays, a storefront).
Added
TableandAlerttake a font family (fontSans,fontMono, …) and default tofontSans. Their cells and raw text used to fall back to the page's own font (a serif on an unstyled page), because the scoped reset never sets one.
Changed
InputandSelectare as tall asButtonat every size. They used a looser line height, so a field was 4.8px taller than the button beside it atmd. Their corner radius now matches too. EveryInputandSelectgets slightly shorter.- List markers sit inside the list box. The marker gutter was 12px at
md, so bullets and10.hung outside it; it is now 16–32px by size. Aniconitem's icon moves into the same gutter, so icon items and plain items share one text column. transitionno longer animates the focus ring, only colors, shadows and transforms.
Fixed
- Words no longer break mid-word in rows and tables. The long-token wrapping added for URLs let a short label beside a full-width control shrink to one letter per line, and split prices and names in table cells. Titles, lists and blockquotes now wrap long tokens too, instead of overflowing on a phone.
- An unselected
RadioGroupno longer shows every radio as selected. - The invalid state is visible.
Input,SelectandTextareadrew their danger ring at 30% strength, fainter than a normal field; it is now full strength. Text fields also use the form-control border token, likeCheckbox,RadioandSwitch. - The active
NavLinkstands out: it is semibold on a stronger tint, and hover no longer looks the same as the current page. A trailingBadgesits at the row's end. Linkand aCardwithhrefdraw a visible focus ring in dark mode and on colored surfaces. It used the browser's default color.- Text stays readable on filled surfaces. Secondary and tertiary text, ghost buttons and links on a filled
CardorSectiontake the surface's text color. filledwithout an appearance no longer turns text white on an unpainted surface.- Whole-page dark mode paints the page, so cards no longer sit darker than the page. Any background you set on
htmlorbodystill wins. Markshows a visible highlight.- An svg in running text stays inline instead of breaking the line.
Tooltip,Popupand menus render in the library font. A stringtitleonModalrenders as aTitle.Modalreturns focus to the button that opened it on every close path.- Escape inside a
Menucloses only that menu, not theModalaround it or the whole menu tree. - Menu items sit flush again; a 16px gap had appeared between them.
- Popups placed above or below keep their own width instead of shrinking to a small trigger, so tooltips no longer wrap one word per line.
- End-aligned and left popups stay at their trigger under
dir="rtl". Modalkeeps a margin on a phone instead of running edge to edge.ModalCloseButtonkeeps its own look under app-widebutton.maindefaults (a filled default made the × invisible) and is square likeIconButton.- A loading
Buttonwith an icon keeps its size. - A read-only
Checkboxno longer toggles, a read-onlySelectcan no longer be changed, and read-only toggles dim like read-only text fields. Fieldpassesdisabled,requiredandreadOnlyto a child control, including everyRadioof a childRadioGroup. Control-only attributes no longer land on its wrapper div.<Link sm>renders atsm; an explicit size used to be ignored.- A size set on the switch input theme reaches the track.
- A disabled
Linklooks disabled, and disabled fields show the not-allowed cursor. Disabled opacity now reads the--disabled-opacitytoken. Selectstays on one line in a row,noShrinkreaches it, and a placeholder option is muted like anInputplaceholder.Textareahonoursrowsbelow three lines.- A clickable
Chipshows the focus ring, and linkedCard,ChipandBadgeget a hover tint. - Hovering another menu item closes an open sibling submenu, and a submenu's trigger keeps its tint while the submenu is open.
Alerttext uses body line-height and a leading icon centres on the first line.- A
Cardwithhrefwarns in development when it contains a button, link or field.
1.4.2
2026-08-22
Fixed
Field's label now scales with the field. The label, description and error were pinned tosmat every size, so<Field xl>grew the control and left its label behind. The label now takes the field's size and the help and error text sit one step below it.Field's inline checkbox and switch line up with their label. The control resolved its line box against the page root instead of the label's font metrics, so it sat high and the switch track overflowed its own box.Field's help and error text stay readable on a filled surface. Their pinned secondary and danger colors lost contrast on a fill; under any filled ancestor, the field itself or aCardorRowabove it, they now take that surface's text color.
1.4.1
2026-08-22
Added
Fieldcan render its own control, instead of only wrapping one passed as a child. Passtype(a control kind such asselect, or a native input type such aspassword,email,date) or one of the six booleanstextInput,textarea,select,checkbox,switch,radiogroup. Surface props (appearance,variant,shape,border,ring,shadow,transparent) route to the rendered control, native attributes go to the control, andrefnow points at the control instead of the wrapper. Layout props andclassNamestay on the wrapper. Children still reach the control, so<option>and<Radio>children keep working.checkboxandswitchlay out inline, control first and label beside it, unless an explicitrow/columnfalls back to the plain stacked layout. Naming no control leaves today's children mode completely unchanged.
Changed
Field'stypeprop is now a closed union of the control kinds and native input types above. It previously accepted any string, inherited from the anchor attributesFieldspreads for tag switching, and Field has nohref-based tag switching of its own, so a value like<Field type="button">was already reaching the wrapper<div>and emitting an invalidtypeattribute at runtime. The type change surfaces that pre-existing bug at compile time rather than removing a working option.
Fixed
- The conflicting-props dev warning no longer fires on composable side toggles.
borderandmarginside toggles compose by design, so<Card borderT borderL>and<Card marginT marginB>are correct usage, yet every component warned that they conflicted. The warning now skips composable categories, matching the rule the resolver itself already used. It still warns in the one case that is genuinely ambiguous there: when ano*reset is combined with a side toggle (<Card borderT noBorder>), where the reset discards the toggle and wins. Fieldno longer emits a danglinghtmlForfor a radio group. Aradiogroupis not a labelable element and never claims the generated id, so the label pointed at nothing.Fieldnow omitshtmlForfor a radio group in both modes; the accessible name continues to come fromaria-labelledby.
1.4.0
2026-08-20
Changed
Portalled floating content waits for hydration before it mounts. A portal has no server markup, so
Popup,ModalandOverlayreturnednullduring SSR — but the first client render portalled immediately, giving React DOM it never emitted server-side and forcing it to throw the subtree away and rebuild it. They now stay closed through the hydrating render and mount on the next one.The gate is on the open state rather than the portal call, so the transition and every element-dependent effect sit downstream of it and run once the element actually attaches. Two consequences worth knowing:
- Portalled content that is open on its first render now transitions in (
entering→entered) instead of appearing alreadyentered. Content opened by interaction is unaffected: hydration is long since complete. portal={false}is untouched. Inline content has server markup, so it still renders server-side and still startsentered— the SSR test that pins this behaviour is unchanged.
Menunow tracks its content element in state rather than a bare ref. Its "focus the first item on open" effect was keyed on the open flag alone, so adefaultOpenmenu focused nothing once the content mounted a render later.- Portalled content that is open on its first render now transitions in (
1.3.1
2026-08-20
Fixed
A
sharpSelect opened a rounded dropdown. The shape props emit Tailwind classes onto the field and never touch--br, so the list — a pseudo-element that cannot take a class — always painted the size-driven radius.Selectnow publishes its resolved shape asdata-shape, which the picker rules read.pilldeliberately still keeps--br: its own radius is effectively infinite and a tall list box given that radius swallows its first and last rows.Modal,PopupandTooltiplisted theshapecategory twice.VISUAL_LAYOUTalready foldsSHAPEin, and all three spread...SHAPEover it again. The duplicate is invisible to the type system — theas constunion is identical either way — but every consumer that iterates the array saw the category twice, which rendered the shape props twice in generated prop tables and produced duplicate React keys.categoryUniqueness.test.tsnow asserts no component names a category more than once.
1.3.0
2026-08-20
Added
Labelsupportsdisabled. It was the only form control without the category, so a disabled field kept a full-strength label.<Label disabled>now dims to the sameopacity-50every other control uses, and a Label containing a disabled control dims with it, so you disable the field rather than both. The dim is opacity-only: a Label wraps its control, sopointer-events-nonewould have made the control it labels unclickable.A wrapper that dims what it contains must not let the contained control fade a second time.
Labeland theSelectwrapper now cancel the nestedopacity-50, which previously rendered a disabledSelectfield at a quarter strength rather than a half.
Changed
The
Selectdropdown takes its colours from the field.option,optgroupand::picker(select)were pinned to--color-bg-primary/--color-text-primary, so afilledordangerSelect opened a plain light list. They now resolve--bg-color/--text-color/--border-color, which options already inherit as children of the field.ghostresolves its background to transparent, which would let the page show through the popup, so it falls back to the theme surface.A disabled option, typically a placeholder, was pinned to
--color-text-secondaryand sat at 1.58:1 on a filled list. It now fades toward the list's own background instead, which reproduces the previous contrast on the default surface and lifts the filled case to 2.71:1.
Fixed
The
Selectdropdown opened upward over the page. The UA styles::picker(select)withposition-try-order: most-block-size, which picks whichever side has MORE room rather than the first side that fits — so any field past the middle of the window opened its list above itself even with several hundred pixels free below. Pinning the order back tonormalopens the list downward and keeps the flip for when it genuinely does not fit. The gap between field and list is nowmargin-blockrather thanmargin-block-start, because the flipped-up position anchors the picker's bottom edge and a top margin left it touching the field.Field's help and error text could vanish on a filledField. Both are rendered by the component with pinned appearances (secondaryanddanger), which an author cannot reach from outside, and--color-text-secondaryIS--color-bg-filled-secondary— so<Field filled secondary>painted its description at exactly 1.00:1 against its own surface. On a filledFieldboth now take the surface's own text colour. A plainFieldkeeps its muted description.
1.2.1
2026-08-20
Fixed
Every appearance on
Imgpainted the same neutral border. An appearance is only a palette: it sets the intermediate--app-*variables, and the variant axis is what maps those onto--border-color.data-variantis emitted only when a variant key is extracted, andImgdeclared the variant category but set no variant default, so<Img border danger>and<Img border success>resolved identically.Imgnow defaults tooutline, like every other component. A plain<Img>is unchanged: with no appearance it still emits neither attribute.This is the same failure as
Spinnerin 1.1.3, reached a different way, so it is now a test rather than a fix.appearanceResolution.test.tsasserts that every component declaring an appearance also resolves a variant, withLinkas the one documented exception (itsLinkVariantClassMapperconsumes--app-textdirectly). Class-name assertions cannot see this class of bug: the class list is identical whether or not the colour resolves.
1.2.0
2026-08-19
Changed
ButtonandIconButtonnow render theSpinnercomponent for their loading state, instead of a private ring the library maintained separately. There was one ring drawn two ways:.vane-button-spinner-ringand.vane-spinnerhad already drifted to different stroke rules once, and every future change to the spinner had to be made in both places.The button forwards its own size, so the ring still matches the button rather than falling back to
Spinner'smddefault, and the ring still inherits the button's text color, so a filled button gets a light ring. It stays decorative:aria-busyon the button already conveys the state, so theSpinner'srole="status"is suppressed and the wrapper isaria-hidden.Two things change for consumers who reach into the theme:
The
.vane-button-spinner-ringclass no longer exists. The element a loading button renders is now.vane-spinner.theme.button.spinner.spinnerElementis gone. Restyle the ring throughtheme.spinnerinstead, which now covers standalone spinners and buttons at once:<ThemeProvider extraClasses={{ spinner: { primary: 'border-4' } }}>
1.1.3
2026-08-19
Fixed
Spinner's appearance props did nothing.<Spinner danger>emitteddata-appearance="danger"and resolved the danger palette, but an appearance only reaches--text-colorthrough the variant axis, andSpinnerhad novariantcategory, so nodata-variantwas emitted and the mapping never fired. Every appearance rendered the same inherited color.Spinnernow carriesvariant(defaulting tooutline, withinheritAppearance), so:<Spinner danger /> {/* the danger color */}<Spinner danger filled /> {/* the on-danger-fill color */}<Spinner /> {/* still inherits its surface */}A bare
Spinneris unchanged: it emits neither attribute and keeps inheriting, which is what makes it readable inside a filledButton,BadgeorCard. The variant carries no background here, since a ring has nothing to fill; it only selects which color of the appearance the ring paints in.The spinner ring was a 1px hairline at
xsandsm. Its stroke was0.125em, and browsers floorborder-widthto whole pixels, so 1.5px and 1.75px both painted as 1px. The stroke now floors at 2px.Button's loading ring andSpinnerhad drifted to different strokes (a pinned 2px against the scaling0.125em), so the same ring rendered at two weights depending on where it came from. Both now share one rule.
1.1.2
2026-08-18
Added
- A
Select's dropdown list is now styled where the engine allows it. A native<select>hands its option list to the operating system, which draws a flat, square, unpadded menu: the options get no layout box at all, so padding, radius and shadow were inert and only their colors landed. Behind@supports (appearance: base-select)the list becomes real DOM and is themed to match aMenu(surface, border, radius, shadow, padded rows, hover, and a checkmark on the selected option). Engines without it keep the OS picker unchanged, and the control is the same<select>either way.
Fixed
- A
sharporroundedSwitchreshaped only its track. The knob was rendered with no props and its theme pinspill, so a square track kept a circular knob. The shape now reaches the knob, and its radius insets from the track's by the track padding, so the two corners stay concentric rather than sharing one radius across boxes of different sizes.
2026-08-18
Fixed
The
Inputerror icon rendered as a solid disc with a slot cut through it, not an exclamation mark. Its stem and dot are meant to be holes punched in the disc, which under the nonzero fill rule requires them to wind opposite to it. The stem did; the dot did not, so it merged into the disc and vanished. The glyph is now sampled pixel-by-pixel in the e2e suite, because both the class list and the icon's geometry are identical either way.A
Select's dropdown list ignored the theme.<option>and<optgroup>default to a transparent background, and the browser paints the popup from the option's own colors, so the list fell back to user-agent colors instead of the field's. Both now carry the surface and text tokens, group labels lose the user-agent italic, and a disabled option is muted.
Changed
- A
Select's chevron turns danger when the field is invalid. The chevron is a sibling of the field rather than a descendant, so the error state was not reaching it and the control's trailing edge stayed neutral whileInputandTextareaboth changed.
1.1.0
2026-08-18
Added
Fieldwires a form control to its label, help text and error message.invalidalready emittedaria-invalid, but nothing associated an error message, so a screen reader announced that a control was invalid without saying why.Fieldowns one id and publishes it, the label id, thearia-describedbylist and the validity to whichever control it wraps.Input,Textarea,Select,Checkbox,RadioandSwitchall read it, and an explicit prop on the control still wins.<Field label="Email" description="We never share it." error={errors.email}><Input type="email" /></Field>Passing an
erroralso marks the control invalid, so the danger cue and the message can never disagree. ARadioGroupis not a labelable element, so it takes the label by reference througharia-labelledbyinstead.Field's own size becomes the control's default, the same wayLabelalready worked.Tooltipdescribes its trigger on hover and on keyboard focus. Built onPopupTrigger's tooltip semantics: the trigger getsaria-describedbywhile open and neveraria-haspopuporaria-expanded, which do not apply.Spinnerpromotes the ring that already existed insideButtonto a component of its own.role="status", sized by the size prop and coloured by the appearance prop.Alertis a live-region surface:role="alert"by default, orrole="status"withpolitefor messages that should wait for a pause.
Changed
CardandStacknow scale their padding down on small viewports, matching the behaviorSectionalready had. A surface's inset is chrome rather than rhythm: it used to stay at its desktop value while the viewport shrank, so nesting aStackinside aCardinside aSectionleft as little as 220px of content on a 390px phone. Both axes step down together so the box stays square.Size Carddesktop / tablet / mobileStackdesktop / tablet / mobilexs12 / 12 / 8 8 / 8 / 4 sm18 / 16 / 12 12 / 10 / 8 md24 / 20 / 16 16 / 12 / 8 lg36 / 28 / 20 24 / 16 / 12 xl48 / 36 / 24 32 / 24 / 16 Desktop values are unchanged, so nothing moves above 1024px.
CardHeader,CardBodyandCardFooterfollow the parentCard's curve. To keep the previous fixed padding, turn the ramp off per component:<ThemeProvider themeDefaults={{ card: { main: { responsiveSizing: false } } }}>.Container,ModalandPopupramp their padding the same way, so every surface now behaves consistently.ContainerfollowsSectionrather thanCard, capping its gutter independently of its block rhythm because a page margin is bounded by the viewport.ModalHeader,ModalBodyandModalFooterfollow the parentModal. Menu dropdowns keep their existing tighter padding at every viewport.
Fixed
Containernow applies the 2:1 inline-to-block padding ratio it has always declared.--aspect-ratio: 2sat at a lower specificity than the per-size layout rule that resets it to1, so the declaration never reached the computed value and a paddedContainerrendered a square inset. The reset moved to a selector components can override.ContainerisnoPaddingby default, so this only affects callers that opted in, where the inline padding doubles: 32px to 64px atmdon desktop.Dividerdeclared the same ratio and is deliberately left at1: it renders withbox-sizing: content-boxandwidth: 100%, so inline padding is added outside the 100% and overflows the parent rather than insetting the rule.
1.0.2
2026-08-17
Added
CHANGELOG.mdnow ships inside the published package. It is the single source of truth for release notes, and the changelog page renders it directly from the installed package rather than keeping a second copy.
1.0.1
2026-08-17
Fixed
- Added the missing
./tablesubpath export.import { Table } from "@vaneui/ui/table"threwERR_PACKAGE_PATH_NOT_EXPORTEDeven though the built chunks shipped, becausetablewas the only component directory absent from theexportsmap. The main-barrel import was never affected.
1.0.0
2026-08-16
First stable release. The latest dist-tag previously pointed at 0.2.0 while all development shipped under the alpha tag, so this is the first time the current architecture is installable with a plain npm install @vaneui/ui.
1.0.0 marks the public API as stable: 55 exported components across layout, typography, controls, and overlays, driven by the boolean props API and themed through ThemeProvider.
Added
- Table family.
Table,Thead,Tbody,Tfoot,Tr,Th,Td,Caption, with the size prop cascading from any container element down to the cells. - Dark mode through the
data-theme="dark"token contract. No component code changes are required, and consumers can declare their own themes by re-declaring the same tokens under any selector. - RTL support. Logical CSS utilities behind the existing props, plus
textStartandtextEndalongside the physicaltextLeftandtextRight. - Nested submenus on Menu, with the full keyboard model including typeahead,
Home,End, and arrow-key traversal. - Icon container mode. Icon gained opt-in padding, shape, border, and ring, and its glyph size decoupled from the font-size variable.
- List markers and spacing.
listCircle,listSquare,listLowerAlpha,listLowerRoman, inside/outside positioning, per-item gap control, automatic marker progression on nested lists, and a ListItemiconprop for custom markers. - Size-driven margin props on layout components and block typography, on the same scale as gap and padding.
- Per-side border widths and logical
borderStart/borderEndprops. alignSelfandjustifySelfprops, with Popup edge alignment driven through them.- Granular inherit props (
inheritSize,inheritColor,inheritBg,inheritBorder) alongsideinheritAppearance. wordBreakcategory, andwhitespaceextended to typography components.hrefsupport onRow,Col, andStack, with a conditional focus ring.
Changed
- Prop names now mirror the Tailwind class they emit, keeping the CSS-property prefix:
fontLight,listInside,insetRing,backdropBlur,placeTop, and so on. Emitted classes,data-*values, and computed styles are unchanged. The rule is in.claude/rules/naming-law.md. - List position defaults to
listOutside. - Gaps and padding scale on a shared accelerating curve, and the icon-to-label gap is role-aware so controls and compact pills space differently at the same size.
- Button and
IconButtonshare a control height. - Badge no longer sets
uppercaseby default. - Label defaults to a stacked column layout, which suits forms. Use
rowfor inline controls such as a checkbox. - Modal always offers a close button, floating it when there is no title.
Removed
- The
brandandlinkappearance values.primarykeeps the same colorbrandhad, and Link still renders its blue through the--link-textcascade, so<Link>itself is unchanged. - The
reversecategory. UserowReverseorcolumnReverse.
Fixed
- Popups and menus opened inside a
Modalnow render above the backdrop, and <kbd>Escape</kbd> closes the innermost overlay rather than the whole modal. Popupgained a maximum width and an inner scroll box, so wide or tall content no longer runs off-screen on small viewports.- Popup arrow alignment for the
*Startand*Endplacements, and a spurious vertical flip in the JavaScript positioning fallback. - Responsive breakpoints are exclusive (
width <the boundary), and combining several*Stackor*Hideprops resolves to the widest. Modalno longer strands focus on<body>when the trigger unmounts.- Error state is announced, not just colored:
invalidemitsaria-invalidand adata-statusattribute, and Input renders a non-color alert icon. - Keyboard focus rings on
NavLink,MenuItem,Link,Card, andChip.
Accessibility
Modalwiresaria-labelledbyandaria-describedbyfromModalHeaderandModalBodyautomatically, and traps focus.Inputgained a muted read-only treatment witharia-readonly.- Blockquote gained a
citeprop for source attribution.
More in Reference