Repository navigation
v0.3.0
Minor Changes
-
fafeb5f: New: the appearance bootstrap script ships.
buildAppearanceBootstrapScript()
returns the small pre-paint script that reads a returning visitor's saved
theme/material/radius/elevation and stamps thedata-fuji-*attributes before first
paint, sopersistno longer flashes the default theme on load. It comes with
APPEARANCE_STORAGE_KEY(thelocalStoragekeypersistwrites) and the
StoredAppearancetype describing the payload.It returns a string; the package never injects it. Where it runs is still the
app's decision -docs/ssr.mdshows the Next.js and Vite placements.Previously every consumer hand-copied this script out of the documentation,
which meant the storage key and payload shape were duplicated in every app that
usedpersistand drifted silently the moment either changed.SPEC.md§3 and
§6 are updated: §6 had stated the package does not ship the script. -
fafeb5f:
@base-ui/reactfloor raised to^1.7.0(from^1.6.0). It is a runtime
dependency, so this changes what resolves in every consumer's tree - an app
pinned below 1.7 will see a duplicate copy installed, or a resolution error
under a strict package manager.1.7 is what the overlay, menu and field primitives are built against here; the
positioner work inuse-untransformed-positioner.tsdepends on its
Positionerbehaviour, and the parts re-exported from it
(DialogRoot,TabsRoot, and the rest) inherit their props from that version. -
fafeb5f:
Cardgains aneffectprop, and its hover treatment now actually
animates.interactiveis replaced byeffect, which names what the card does instead
of asserting that it is interactive:effect="lift"- the previous treatment: scales up slightly, tips a degree
and deepens its shadow. Pure CSS, so it still works in a Server Component.effect="tilt"- new. Tracks the pointer and tilts the card in 3D towards
it, springing back on leave (the reference is motion.dev's tilt-card).
Skipped for touch pointers and underprefers-reduced-motion: reduce.
interactivekeeps working and maps to"lift".effectwins when both are
given, soeffect="none"opts a card back out.Fixed: the lift's scale and tilt snapped instead of easing. The transition
listedtransform, but Tailwind v4 emitsscale-105and-rotate-1as the
individualscaleandrotateproperties, neither of which was in the list.
Measured 60ms into a hover, the shadow was mid-interpolation while the scale
and rotation had already jumped to their final values. Both properties are now
named in the transition.Fixed:
prefers-reduced-motion: reducedid not suppress the lift. For the
same reason, themotion-reduce:transform-noneguard could not undo ascale
or arotate- a reduced-motion visitor still got the full scale and tip. Nor
could amotion-reduce:scale-100override fix it:.hover\:scale-105:hover
carries a pseudo-class and a media query contributes no specificity, so the
hover rule wins regardless of order. The moving half of the lift is now gated
behindmotion-safeinstead, so for a reduced-motion visitor those rules are
never emitted at all. The colour and shadow changes still apply.The tilt variant lives in its own client module (
CardTilt) so thatCard
itself stays free of a"use client"directive, which Server Component
consumers passingCardas a prop depend on. -
fafeb5f: New:
InfiniteScroll. Loads the next page when the end of a list comes
into view, for use in place of a pager or a "load more" button. Built on an
IntersectionObserver over a zero-height sentinel rather than a scroll handler,
which is what keeps a long list smooth. Re-entry is guarded: the observer can
fire several times before the parent re-renders with the new page, and without
the guard one scroll to the bottom fires three or four duplicate fetches. Works
with any content, includingDataTable(setpageSizeto the number of rows
fetched so far, since infinite loading is what replaces the pager).New:
FloatingActionBar. A speed dial: a circular trigger that fans a
column of labelled actions out from itself (the reference is motion.dev's
floating action button). Two details carry the effect - the actions stagger in
nearest-the-trigger first and reverse on close, so the column unfurls from the
button rather than a menu appearing; and each step further out is slightly
smaller, which is what gives the column depth instead of reading as a list.
The column is absolutely positioned against the trigger rather than being a
flex sibling, so opening it cannot move the trigger - the one element that must
stay put, since the pointer is already on it - and a dial can be dropped
anywhere without reserving space for its own expansion.directiondefaults to
"auto", which opens downwards when the dial sits near the top of the viewport
and upwards when it sits near the bottom.Deliberately not built on
Popover: the actions belong to the trigger, and a
portaled popup would cross-fade a separate surface in and lose that. Collapsed
actions stay mounted so they can animate, but areinertand hidden from
assistive technology. Takesdirection("auto"default,"up","down").Alertredesigned. It was a uniformly tinted block, which had to stay pale
enough for body text to sit on and so left every variant looking like the same
faint card. The tone is now washed in from the leading edge and fades out
before the text, letting it start saturated enough to identify the variant at a
glance, and the icon rides its own raised tile.Fixed: the tab indicator briefly overlapped the neighbouring tab. It eased
on--fuji-ease-spring, which overshoots - measured travelling to 198px on a
194px destination - so on the pill variant the opaque tile crossed the next
label before settling back. It now uses a decelerating curve that never passes
its slot.Fixed:
Popover's arrow rendered as an outlined diamond. It was a
rotate-45square with a border on all four sides; since only half of it is
ever visible, the far two edges showed above the panel. It is now a clipped
triangle in the panel's own fill, the same approachChatBubbleuses for its
tail, and meets the panel edge with no seam.Fixed:
AvatarGroup's overlap read as damage. The separating ring was
painted in--fuji-border-strong, drawing a grey arc across every neighbour.
It now uses the colour of the surface behind (overridable per group with
--fuji-avatar-group-ring), so each avatar reads as cut out of the one below,
and earlier avatars stack above later ones so "+N" tucks under the last face.Fixed:
RadioGroup's selection dot could not animate. It was unmounted
while unchecked, and an element that does not exist cannot transition, so
selection popped in instantly. It now stays mounted and scales in on the same
spring asSwitch's rolling thumb.Fixed:
Image's fullscreen close button was anchored to the viewport, so
on a wide display it sat in the far corner of the screen rather than near the
photo it closes. It is now pinned to the image's own top-right corner, on its
own scrim since the photo beneath can be any colour. The lightbox backdrop is
also darker than the shared overlay scrim: a photo is shown edge-to-edge with
nothing behind it, so the page stayed visible around it and competed with the
image.Fixed:
ChatBubblehad no shadow. Abox-shadowpainted a hard edge
across the join with the tail, which is why it was removed. The cast is now a
chainedfilter: drop-shadow()on the wrapper holding both bubble and tail, so
it follows their combined silhouette - and it scales with the elevation
appearance.Fixed: chat bubbles were invisible in light and dark. The bubble fills were
defined only for the glass theme, so the other two fell back to
--fuji-surface- exactly whatCardpaints, making an incoming bubble the
same colour as the card behind it.Glass tuning. Surface blur radii drop from 20/28/36px to 6/10/14px. The
larger radii obliterated everything behind the surface, which reads as a tinted
slab rather than glass; overlays were already corrected to 8px and surfaces
were missed. The white inset "edge" highlight is also removed from every glass
shadow - at 20% it read as a hard line along the top of every panel rather than
a specular edge.BottomNavigationtakesonItemSelect. An item without anhrefwas
inert markup - the bar could only be driven by navigation, so a tab bar
switching a local view had no way to report the choice, whichNavbarhas been
able to do since 0.2. Such an item now renders as a real<button>when a
handler is given, so it is focusable and operable from the keyboard rather than
being a mouse-only<span>. Its labels also move to the new--fuji-text-2xs
with a smaller icon.Fixed: a nested overlay panel was translucent in glass.
Calendar's
month/year chooser opens inside another glass surface, so it has no
backdrop-filter of its own - an ancestor with one is already a backdrop root -
and the tint was all it had. At 48% the date grid read straight through it.Fixed: a bottom
Drawerhad no bottom padding. Its safe-area inset was
applied as a barepadding-bottom, which replaced the panel's own padding
rather than adding to it - and since that env var is0pxon any desktop
browser, the sheet's last control sat flush against the screen edge. The inset
is now added to the panel padding, andside="top"gets the mirror treatment.Charts animate between data sets. Re-rolling a chart's data snapped: the
mount-time entrance keyframes never re-run on an element that stays mounted,
and neither a path'sdnor a bar'sy/heightis animatable as the
attribute React writes. Every coordinate the plots draw is derived from
series, so they are now handed an interpolated copy each frame instead - the
line bends to its new shape, the bars grow and shrink, the donut's ring and its
legend figures travel, and the axis rescales with them. A change of shape
(different series or labels) still snaps, since there are no pairs of points to
interpolate between.prefers-reduced-motionskips straight to the new data,
and thesr-onlydata table always holds the real values, never a frame in
between. The crosshair and tooltip are now derived from which point they are
pinned to rather than from a snapshot of where it was, so they travel with it.Toastnow carriesAlert's treatment. The two expose the same four
status variants, so a success toast and a success alert have to be recognisable
as the same thing; the toast had a bare tinted glyph against a plain overlay.
It now gets the tone wash from the leading edge and the icon on its own raised
tile, shared from one module rather than described twice.Drawerspans its edge again onside="top"andside="bottom". They had
been changed to a detached, width-capped card, which made them the only two
sides that did not behave like a drawer. That presentation is now
variant="sheet", available on all four sides: inset all round, rounded on
every corner, with the dimmed page still visible around it - for a short,
self-contained task rather than navigation or a long form.Glass is more transparent. Surface tints drop from 30/38/50% to 22/28/42%
and the blurs rise from 6/10/14/8px to 10/14/16/12px. Tint and blur work
against each other - tint is what the surface adds, blur is what it lets
through - and the previous pairing had enough of the first that the second
barely showed, so panels read as dark cards rather than as a material with a
scene behind it. Measured on the shipped atmosphere gradient at its brightest
sampled pixel, white foreground holds 7.7:1 on-subtle, 8.1:1 on-surface
and 10.2:1 on-strong.--fuji-surface-overlaydeliberately does not follow
them down: it is the tier that covers content the theme does not control, where
the tint alone has to carry legibility.(That gradient stopped being glass's automatic background later in this
release - it is now the opt-in.fuji-glass-atmosphereclass, and
--fuji-backgroundcomes from the activethemeby default. These alphas
are unaffected: they composite over whichever background is behind them
either way. Re-measured against the real theme backgrounds: light + glass
card text 7.87:1, dark + glass 17.05:1 - both clear of the numbers above, not
below them. Seedocs/theming.md.) -
fafeb5f: Fix text that sat on
--fuji-surface-strongwashing out under dark glass.Under dark glass that token is a translucent white fill. It is white on
purpose - bare, textless fills (Switch, Slider and Progress tracks) have nothing
else to separate them from a near-black page, and at a dark tint they measured
1.01:1 against it, the literal same pixel. But a white tint lightens toward
whatever is behind it, so anything painting TEXT on it lost contrast as soon as
glass sat over a bright backdrop.Two different fixes, because the two cases want different things:
Chips keep their translucency.
softClasses("default")- shared byBadge,
Avatar's fallback andMultiSelect's value chips - now paints
--fuji-surface-subtle, which tints dark under dark glass and so darkens the
chip whatever is behind it. Every other tone in that recipe already pairs a
low-alpha tint of its own hue with ink of that hue;defaultwas the only one
borrowing a surface token.chip (dark glass, over the atmosphere) before after Badgedefault pill2.26:1 6.00:1 Avatarfallback initials2.36:1 6.00:1 MultiSelectvalue chips2.92:1 8.44:1 Menu highlights invert instead.
DropdownMenu,Select,Comboboxand
MultiSelecthighlighted rows now use the fill/text inversion
(bg-fuji-contained-default/text-fuji-default-foreground) that every other
selection indicator in the library already uses, and thatCommandMenumoved to
for this exact reason. A highlight has to read as selected, which a subtler
tint cannot do.DropdownMenu: 3.35:1 -> 13.48:1.--fuji-surface-strongitself is unchanged, so the bare fills it was tuned for
keep both their translucency and their 3:1 separation from the page. -
fafeb5f: Breaking: glass no longer paints its own background, and the gradient
atmosphere is no longer automatic.material="glass"used to override--fuji-backgroundto a fixed#2c323b,
identical in both themes, and--fuji-foregroundto a fixed white, with
--fuji-page-background: transparentso a decorative atmosphere gradient
could paint through instead. That meanttheme="dark" material="glass"
wasn't actually dark: it rendered the exact same canvas as
theme="light" material="glass", a third color scheme that happened to be
blurry, not "dark theme with glass surfaces."Glass now inherits the active theme's palette.
--fuji-backgroundand
--fuji-foregroundfall through the cascade to whichever theme is active on
the same element -theme="dark" material="glass"renders on dark's own
#0f0f0e,theme="light" material="glass"on light's own#eceae6, the
same two valuessoliduses. Glass itself now contributes only what makes it
a material: translucent--fuji-surface*fills,--fuji-backdrop-blur*/
--fuji-backdrop-saturate*, and a small set of tokens (the "contained" tone
fill among them) that intentionally stay fixed literals in both themes
because they pair with glass's own high-alpha fills rather than with the
page - theme-sourcing those alone measured as low as 1.11:1 (invisible text
on a primary button under dark + glass). Each is commented in place in
tokens.csswith its own contrast numbers.If your app used
material="glass"and relied on the gradient canvas
appearing automatically, you will now see the theme's flat background
instead. Fix it with one line - apply the shipped.fuji-glass-atmosphere
class yourself, or supply your own backdrop:<FujiProvider theme="dark" material="glass"> - <App /> + <div className="fuji-glass-atmosphere min-h-screen"> + <App /> + </div> </FujiProvider>
That class paints fixed decorative art - a slate-blue/terracotta/teal canvas
under a dark tint, white/tan/blue-grey under light - not a rendering of
--fuji-backgroundor anything else you've customized, so it looks the same
regardless of how your theme is set up. It restores the pre-upgrade
screenshots, not "your theme with glass on top"; supply your own backdrop
instead (a photo, a brand gradient, a plain color) if you want glass over
something that reflects your own theme or product.The class is safe to render unconditionally: its selector is scoped to
[data-fuji-material="glass"] .fuji-glass-atmosphere, so it stays inert
undersolid.No type or prop changes - this is a rendering-only fix. See
docs/upgrading.mdfor the migration anddocs/theming.md's Glass section
for the full picture. -
fafeb5f: Breaking:
glassis no longer athemevalue.FujiThemenarrows to
"light" | "dark";glassmoves to a new, independentmaterialaxis
(FujiMaterial = "solid" | "glass", default"solid", mirrored as
data-fuji-material).FujiProvidergainsmaterial/defaultMaterial/
onMaterialChange, andmaterial/setMaterialonuseFujiConfig().
materialis a real persisted preference, alongsidetheme/radius/
elevation, whenpersistis set.This was forced by a real limitation, not a naming cleanup: with
glassas a
thirdthemevalue, turning glass on overwrote whatever light/dark
choice a user had made, so "dark mode + glass" was never reachable as a
combination, and glass always fell back to its own dark tint regardless of
the app's theme.themeandmaterialare now orthogonal - either theme
renders in either material.Migrate:
-<FujiProvider theme="glass"> +<FujiProvider theme="dark" material="glass"> <App /> </FujiProvider>
The glass material's tint derives from
theme, with no separate axis to
set it independently (dark theme -> dark tint, light theme -> light tint) -
so a light-themed app now gets light-tinted glass (white surfaces, dark
text) automatically, whichtheme="glass"alone could never produce before.
An early draft of this same change carried a dedicatedglassTintprop
(mirrored asdata-fuji-glass) for overriding the tint independently of
theme, but it was removed before this reached a release: a page's theme
and its glass tint never actually needed to diverge in practice, so the
extra axis just meantthemealone didn't fully describe a glass panel's
appearance. To show glass tinted differently from the surrounding page (e.g.
a dark hero photo shown under an otherwise light-themed page), nest a
FujiProviderwith the tint'stheme(andmaterial="glass"re-declared -
nested providers don't inherit unspecified axes from an ancestor) around
just that region:<FujiProvider theme="light" material="glass"> <App> <FujiProvider theme="dark" material="glass"> <DarkPhotoSection /> </FujiProvider> </App> </FujiProvider>
A stored
{"theme":"glass"}preference from before this release keeps
rendering the same dark glass it always did -persist's reader and the
pre-paint bootstrap script both coerce it to
{theme: "dark", material: "glass"}on read. If you call
buildAppearanceBootstrapScriptdirectly, pass it amaterialdefault
alongsidetheme/radius/elevation.CSS selectors for glass are now keyed on
data-fuji-materialrather than
data-fuji-theme, so any app CSS overriding a[data-fuji-theme="glass"]
selector needs the equivalent rename to[data-fuji-material="glass"]. The
light-tinted material selector is[data-fuji-material="glass"] [data-fuji-theme="light"].See
docs/upgrading.mdfor the full migration guide. -
fafeb5f: Glass overlay material reaches every floating surface, the
earthtone is removed, and a full-matrix audit fixes contrast and box-sizing defects.Breaking: the
earthtone is gone. It is removed fromComponentTone, from
every component's tone map, and from the tokens. Useforest(or another tone)
instead.Glass overlays
- Portaled popups never received the overlay material at all. The
@supports
blur rules were descendant selectors
([data-fuji-material="glass"] .fuji-glass-surface-overlay), but Dialog,
AlertDialog, Drawer and CommandMenu portal todocument.bodyand carry
data-fuji-materialon the popup element itself, so nothing matched and
backdrop-filtercomputed tonone— the material's blur never reached a
single modal. The selectors now also match an element carrying its own
material attribute, not just a descendant of one. Popovers, menus and
selects had been matching only by accident, via Base UI's positioner wrapper. - Calendar's month/year chooser was see-through, with day numbers legible
through the month list. It renders inside the calendar card instead of
portaling, and an ancestor with abackdrop-filterbecomes a backdrop root, so
no blur there can ever see the grid it covers. It now uses a near-opaque
.fuji-overlay-panel-nested(light/dark fall back to their already-opaque
--fuji-surface-overlay). - Carousel's play/pause control sits directly on slide media; it now takes
the overlay material instead of a thinned tint plus a generic 4px blur. - Popover's arrow composited on its own and read as a lighter, unblurred chip
on the panel edge; it now matches the panel.
ChatBubble — the tail now sits at the bubble's bottom corner (bottom-right
outgoing, bottom-left incoming) rather than mid-side, and the joined corner is
squared while it is shown so bubble and tail read as one shape. The bubble stays
borderless so nothing draws a line across the seam.Contrast (WCAG AA), light theme
--fuji-foreground-mutedmeasured 4.35:1 on--fuji-surface-strong—
visible as washed-out secondary labels on any card using that surface.
#6c6963→#67645e.--fuji-forestmeasured 4.30:1 as text on a white surface and ~3.4:1 on its
own-softtint.#558467→#3f6b4f.- The
:rootno-provider fallback for--fuji-sun*had drifted from the light
theme's split sun and is back in lockstep.
Box-sizing (no preflight ships with this package, so these sized themselves
wrong)- RadioGroup: selection thickens the border to 2px, so the control grew from
20px to 22px on check — the dot visibly jumped and nudged its label. - Checkbox:
size-5plus a 1px border rendered a 22px box, misaligned
against its label. - Slider: the thumb sat 2px proud of its track.
- Kbd and Navbar items: sized 2px larger than declared.
FormField marked
invalidvia the root prop showed the red border and
aria-invalidbut never rendered its<FormField.Error>message, because Base
UI'sField.Erroronly displays for a validation result it produced itself.Ripple is now on by default, on every button. It was opt-in via
rippleon Button and absent from IconButton entirely, so most buttons never
had it. The hook moved tolib/use-rippleand both components share it, so a
press feels identical on either; passripple={false}to suppress it.ChatBubble no longer shows a seam between bubble and tail. The bubble and
its tail are two separate elements, and two translucent fills of the same
declared colour do not meet cleanly at their seam - under glass the tail read as
a visibly darker wedge. Removing the tail's blur was not enough on its own, and
neither was removing the bubble's drop shadow (which had been painting over the
tail, showing through its translucency); both helped, but the seam only
disappeared once both fills were made opaque. ChatBubble now uses opaque
--fuji-chat-bubble-incoming/-outgoingfills under glass, falling back to
the already-opaque--fuji-surface/--fuji-contained-defaultin light and
dark. Opaque is also how chat clients actually draw bubbles, and matches the
HIG's rule that the content layer uses plain surfaces rather than glass.Avatar answers to the elevation axis. It carried no
--fuji-shadow-*token,
soregularandfloatingrendered identically - the setting was a no-op.CircularProgress's track is visible across the whole glass atmosphere. It
used--fuji-border-strong, an 18% dark tint under glass, which disappeared
against the atmosphere's dark end - the ring read as a bare coloured arc with no
track. The new.fuji-progress-trackuses a light tint under glass only.BottomNavigation gains an
indicatorprop ("none" | "dot" | "pill" | "circle") for marking the active item. Active state had been colour only, which
is not a sufficient visual indicator on its own (WCAG 1.4.1).Glass surfaces actually look like glass now. Two things were missing.
The overlay tier sat at
rgb(12 14 18 / 82%)with ablur(44px)backdrop -
an opaque-looking slab twice over: the tint was nearly opaque, and a 44px
gaussian averages away every recognisable shape behind the panel, so even a
translucent tint had nothing visible through it. (The blur also never applied
to portaled popups at all - every glass blur rule was a descendant selector,
and portaled popups carrydata-fuji-materialon themselves; the selectors
now match elements carrying their own material attribute too, and the
positioner'stransformis folded intoleft/topso no ancestor
interferes with backdrop sampling.)The final overlay material is
rgb(12 14 18 / 48%)with
blur(8px) saturate(140%)- chosen from a side-by-side recipe comparison over
a striped backdrop: 6-12px of blur reads as glass, 18px+ reads as a slab, and
the references (e.g. Raycast's window materials) sit at blur(2-10px).
Legibility lives in the tint rather than in brightness/contrast clamps inside
the filter, which had been darkening whatever the blur sampled and flattening
the detail that makes glass read. The 48% tint is light enough that the panel
visibly lightens and darkens with its backdrop - over featureless bright
regions that tracking is the only glass cue there is. The contract, stated
plainly: on the theme's own canvas and typical mixed content, white text sits
at 7:1+ and muted (raised 82% -> 90% white) at 5:1+; over the very brightest
content a photo can contain, white text still holds ~4.6:1 but muted text can
dip below AA - the trade every OS glass material makes. For text-heavy UIs
over bright media the intended answer is a light-tinted glass surface (68%
frosted white, dark ink, no brightness lift - what atheme="light"glass
region renders), andprefers-reduced-transparencyflattens everything to
opaque.Glass surfaces also had no inset highlight at all, in either elevation. A
translucent panel only reads as glass when its rim catches light (Raycast's
window usesinset 0 0.5px 0 rgb(255 255 255 / 30%)for this); without it they
were flat dark slabs however much blur sat behind them. The new
--fuji-glass-edgeis appended to the glass card/panel/overlay shadows in both
regularandfloating.The
lightglass tint had the same problem and gets the same treatment: its
overlay wasrgb(255 255 255 / 86%), another opaque slab. It is now 60% white,
and because this tint's foreground is dark its clamp inverts -brightness(160%)
lifts the backdrop instead of darkening it, so dark text still holds ~6.4:1
against the worst case (a pure black backdrop) while the panel reads as frosted
glass. Its lit edge is brighter than the dark tint's, since a low-opacity white
hairline does not register on a light fill. - Portaled popups never received the overlay material at all. The
-
fafeb5f: New:
Keyboard. A full on-screen keyboard in the same key vocabulary as
Kbd-full(100%, numpad included),tkl(75%),compact(65%),phone
andnumpad.Every cap is a button. Pressing one plays a real stroke: the cap travels down
onpointerdown(not on release, which lagged behind the finger), lights up in
toneat the bottom of its travel the way a backlit mechanical key does, and
springs back on--fuji-ease-spring. The light fades out with the rebound
rather than latching, and the whole stroke runs on--fuji-duration-base, so
prefers-reduced-motion: reduceremoves it for free.onKeyPressreports the
struck cap;interactive={false}drops the board to plain<kbd>caps for
documenting a shortcut, andcaptureKeysholds caps down from the real
keyboard instead. No cap carries colour untilaccentKeysnames one.Caps sit on a quarter-unit CSS grid whose unit is the smaller of the chosen
sizeand whatever divides the container evenly, so a nineteen-unit board
keeps its proportions from a wide page down to a narrow column with no resize
listener. The board is a single tab stop with a roving tabindex - the arrow
keys, Home and End move between caps, chosen geometrically so a move past the
numpad's two-unit+andEnterlands where the cap actually is.floatingdocks the board over the page instead of laying it out in flow:
anchorpicks the box it fills (viewport, fixed and spanning the screen, or
parent, absolute inside the nearest positioned ancestor),placementpicks
the edge, andopen/defaultOpen/onOpenChangedrive it from an input or a
button. It unmounts while closed, closes on Escape and on an outside press
(dismissible={false}to opt out;triggerRefexcludes the control that opens
it, so its own click toggles rather than closing and reopening), and swallows
the mousedown that would otherwise blur the field it is typing into. The
margins either side of the board stay click-through, so a dock never
intercepts presses on the page it is covering. No backdrop, by design - a
keyboard types into content you still need to see and scroll.New
phonelayout.floatingis meant to dock a board over a phone-width
page, butcompact- the narrowest layout until now - is sixteen columns
wide and renders a 16.7x16.7px cap at a 375px viewport, well under the WCAG
2.5.8 AA 24x24 target floor.phoneis ten columns (the sizing already takes
min()against the container, so column count was the only lever): a digit
row,qwertyuiop,asdfghjklplus;, a Shift/Backspace row, and a bottom
row of,, Space,.and Enter - forty-three caps, no?123symbol switch
(no suchKeyboardEvent.codeexists), and no shifted digit-row symbols, since
a phone board has no symbol mode to switch into.Shift and Caps Lock work. Both latch and light a lamp on the cap - sized
from the cap unit, lit in the board'stone, in the top-right corner, the
only one clear of a wide cap's left-set legend. Shift takes the cap's second
legend where it has one (1types!) and otherwise flips case; Caps Lock
only flips case, because it is not Shift; the two cancel, so Shift on a locked
board types lowercase. A Shift armed by clicking is sticky - it applies to the
next cap and lets go, there being nothing to hold down on a drawn keyboard.
Both caps carryaria-pressed, so the state is announced as well as lit.One modifier state, shared with the real keyboard. Under
captureKeys,
holding the physical Shift engages the on-screen cap - so a cap clicked while
it is down reports its shifted value - and the real Caps Lock moves the lamp.
A physical release only lets go of a modifier the physical key engaged, so it
cannot cancel one armed by clicking.A board can no longer outgrow its container. A docked board is sized
against the screen rather than by the px cap scale that suits a diagram inside
a card:sizepicks roughly 45vw / 60vw / 75vw, and a newwidthprop
overrides that with any CSS length or a number of pixels, in flow as well as
docked. Every one of those is an upper bound - the cap unit is still the
smallest of the target, the size's ceiling and what the container can give, so
the board resizes with its parent and can never overflow it.anchor="parent"
drops the screen-relative target altogether and is measured by the parent
alone.Keyboardjoins the roving-tabindex composite widgets documented in
README.mdanddocs/accessibility.md, and a board whoselayoutchanges
under it keeps its tab stop - a remembered cap the new layout does not contain
used to leave every cap attabIndex={-1}, dropping the board out of the tab
order until it unmounted. The forwarded ref is composed rather than an
imperative handle, so a consumer's callback ref is no longer torn down and
re-attached on every render, and it reportsnullhonestly while a floating
board is closed. A cap drawn blank byhideLabel- the space bar - now carries
its accessible name on the static<kbd>board as well as the interactive one.New
soundprop. A short click on every press, synthesised with an
oscillator and a gain ramp through the Web Audio API - no asset, no
dependency, nothing to fail to load. Off by default, and the audio context is
created on the first press rather than on mount, so a board nobody touches
never opens one.Kbdgainssize(sm|md|lg, defaultsm), for legends set
beside larger type or beside aKeyboard. Its corners now follow
--fuji-radius-iteminstead of a hardcoded 6px, so a consumer on
radius="soft"sees them grow from 6px to 10px on upgrade; it also gained
fj:box-border, so the chip's border now sits inside its declaredmin-w
instead of rendering 2px wider than it. -
fafeb5f: Make
--fuji-glass-atmosphere-imagehold the light scene under light theme, and
lighten--fuji-fireon dark glass.The atmosphere token had a single unqualified declaration holding the dark
scene. The light scene existed only as a literal inside
[data-fuji-material="glass"][data-fuji-theme="light"] .fuji-glass-atmosphere,
a rule specific enough (0,3,0) to outrank thevar(--fuji-glass-atmosphere-image)
rule (0,2,0) that is supposed to drive that class. So the class painted
correctly in both themes and nothing looked wrong - but the token itself was the
dark scene under light theme, and its own documentation invites consumers to
reference it when building their own backdrop. Anyone who did got a dark scene
under a light app.The light scene now lives in the token, byte-for-byte the gradient that rule
painted, and the rule is gone. One source of truth; the rendered output is
unchanged in both themes. A regression guard now rejects any theme-specific rule
that hardcodes this background-image behind the token's back - the same drift
the neighbouring parity test already guarded against one specificity level down.--fuji-fireon dark glass goes#ffa8a8->#ffb4b4. Used as text on a glass
surface over the atmosphere's brightest pixels it measured 4.32:1, the only one
of the four accents to miss AA there (forest 4.87, water 5.04, sun 5.09). It is
now 4.70:1, and unchanged at the scene's dark end, which stays above 9:1. -
fafeb5f: Fix bare fills being invisible under
theme="light"+material="glass", and
document which components the glass material applies to.--fuji-surface-strongis the bare-fill tier: Switch, Slider and Progress
tracks,Skeleton,Image's placeholder,Chart's gridlines and loading
bars, and Alert/Toast's default-tone wash. Those paint no text, border, shadow
or blur, so the fill colour is the only reason they are visible - which means
it has to tint away from the surface behind it. Under light glass it tinted
white on a white-ish surface instead: aDataTablein itsloadingstate
composited to 1.03:1 against its own card, so a loading table was
indistinguishable from an empty one. The same story measures 3.19:1 under dark
glass and 1.26:1 under light solid, so this failed in exactly one of the four
theme x material combinations. Light glass now tints dark at 15%, which
reproduces light solid's own#e7e5e0over a glass card plus headroom for the
backdrop showing through (1.38:1), so togglingmaterialno longer decides
whether these controls are visible..fuji-glass-surface-strongnow resolves to--fuji-surface-raisedrather
than--fuji-surface-strong. The class is advertised for "inputs, text-heavy
areas", but it was inheriting the bare-fill tint, which tints away from the
page in both glass materials and therefore away from any ink sitting on it.
-raisedis byte-identical to-strongin both solid themes and under light
glass, so only dark glass changes - from the white bare-fill tint to the dark
content tint, which is the direction a text-bearing panel needs there.docs/theming.mdgains a "What gets the material" section. Glass is a material
for cards, chrome and overlays, not for the control layer - acontained
Button under dark glass is a 92%-opaque cream fill with a cast shadow, not a
blurred panel. That was deliberate and consistently applied, but it was only
ever written down in a source comment that pointed at a contributor-facing file
which did not contain the rule, so consumers had no way to tell the behaviour
from a bug.Not addressed, and still open by design: neither light material reaches the
3:1 non-text contrast floor for these bare tracks - light solid's#e7e5e0
measures 1.05:1 against the page. Clearing 3:1 against a page that bright needs
a medium-dark grey in both materials, which is a visual-identity decision
rather than a token fix. -
fafeb5f: A shared motion system, and three bugs that only a rendered page could catch.
Overlay, indicator and chart motion are now named recipes in the shipped
stylesheet rather than per-component utility strings. Eleven overlays each
carrying their owntransition-[transform,opacity] duration-… ease-…had
drifted into four durations and two scales for what a user reads as a single
gesture.Dialog/AlertDialog/CommandMenu/Imagescale and rise on a
spring; menus and popovers scale from their own--transform-origin;Drawer
deliberately does not spring (an edge-anchored panel that overshoots opens a
gap at the edge it should be flush against);Toastdoes. Every recipe
collapses underprefers-reduced-motionbecause its durations are tokens.SegmentedControl's selection slides. It used to be painted onto whichever
tab was active, so it teleported. It is now a single raised object that moves
and resizes between slots - the same.fuji-raisedtreatment Button's
contained appearance uses, so "the selected thing" looks the same wherever it
appears.Tabsshares the timing.LineChartgainsarea- a soft gradient fill under each line - and both
the stroke and the fill draw themselves in. The stroke usespathLength="1"
so nogetTotalLength()measurement is needed.Fixed:
Chartcould render permanently blank. Its entrance was a
transition switched on from arequestAnimationFramecallback, leaving the
chart atopacity: 0until that frame ran - which in a backgrounded tab or an
automated browser never happens. It is a keyframe now, so the worst case is
that it appears instantly. This also removes auseState/useEffectpair per
chart.Fixed: the dark theme painted a light page.
:root { --fuji-page-background: var(--fuji-background) }looks like a
redirect but is not - a custom property whose value is avar()is
substituted where it is declared, so it resolved once against:root's light
value and inherited that concrete color into the dark theme. Dark surfaces sat
on a light page.src/styles/tokens.test.tsnow fails if any theme overrides
a token that another:roottoken is defined in terms of without restating it.Fixed:
Carouselinjected a stylesheet per instance. Each one rendered its
own<style dangerouslySetInnerHTML>keyed by a generated id, purely to get
three media queries; six carousels on a page meant six stylesheets, six
useIdcalls and sixsuppressHydrationWarnings. The breakpoints ship in the
stylesheet now and only the numbers ride in as inline custom properties. No
dangerouslySetInnerHTMLremains anywhere in the package.Smaller things.
useControllableStateupdates itsonChangeref in an
insertion effect rather than a passive one (a deferred passive effect could
leave it holding the previous render's handler) and warns in development when
a component switches between controlled and uncontrolled.IconButtonno
longer carries"use client"- it has no hooks, and the directive kept it out
of Server Components for no reason. -
fafeb5f: The motion and composition work from the design brief, built against the
reference pieces one by one and checked in Storybook.Motion (motion.dev references, no animation runtime):
- Material ripple -
Button ripplenow grows a circle from the press
point that STAYS while the pointer is held and fades on release, one node
per press (a double-tap shows two). Driven with the Web Animations API
because the two halves have independent timing; skipped under
prefers-reduced-motion. - Number trend -
Statisticrolls each digit on its own vertical strip
(staggered per column, on the spring) and flashes forest/fire in the
direction of a change. Markup still carries the final value for SSR and
the figure is exposed as one accessible name. - Line graph -
LineChartdefaults to a monotone cubiccurve="smooth"
(never overshoots the data), takesstrokeWidth, draws its stroke in, and
has a crosshair that snaps to the nearest x as the pointer moves. Y ticks
are rounded to the data's magnitude instead of leaking4.050000001. - Coverflow -
Carousel effect="coverflow"centres the active slide with
neighbours rotated away in 3D and scaled by distance; loop, swipe and
controls unchanged. - Sheet modal -
Drawerfollows Base UI's drag gesture (the sheet moves
with the finger, springs back on a short drag, dismisses on a long or fast
one) and a bottom sheet shows a grab handle. - Donut - the ring draws in clockwise on mount, the hovered segment
thickens, and the redundant frame legend is gone. Centre label no longer
overflows the hole.
Reference look:
- Floating elevation deepened: raised objects cast a long, soft shadow
and containers get a wide halo, as in the reference. - Pagination is a white pill with grey tiles and a raised black current
page; Checkbox is a grey well that becomes a raised tone tile when
checked;Tabs.List variant="pill"gives tabs the same sliding raised
indicator as SegmentedControl. BottomNavigationgainsvariant="floating"(detached pill) and
action(a raised centre button in a notch cut from the bar).- Carousel arrows sit on a translucent disc so they read over any photo,
and the indicator dots are 24px hit targets rather than 6px. - Glass modals are near-opaque (82% tint) instead of a grey wash.
New stories: dashboard area chart, floating donut, vehicle-details card,
floating notched bottom navigation, coverflow carousel, pill tabs. - Material ripple -
-
fafeb5f: Fix the documented-but-missing named sub-exports, the unstyled provider-less
render, and CommonJS type resolution.Named compound sub-exports now exist.
README.md,docs/nextjs.mdand
SPEC.md§4 all instruct Server Component consumers to import the named form
(import { DialogContent } from "@fujiui/react") because a static property
read (Dialog.Content) fails across a"use client"boundary. Those exports
were documented but never actually exported, so following the documentation
was a compile error. Every compound now exports its parts by name alongside
the dot-access form, and the two are asserted to be the same reference:AlertDialog,Card,ChatBubble,Collapsible,Dialog,Drawer,
DropdownMenu,Fieldset,FormField,List,NavigationMenu,Popover,
RadioGroup,Sidebar,Table,Tabs,Tooltip— e.g.DialogContent,
DialogTrigger,CardHeader,TableRow,TabsList,FormFieldLabel.Also newly exported:
CarouselHandle(needed to type auseReffor
Carousel's imperative handle),TableProps,TableRowProps,LinkColor,
ButtonGroupItem,CollapsibleTriggerProps,NavigationMenuPortalProps.Provider-less and pre-hydration renders are styled. 17 core tokens
(--fuji-surface,--fuji-foreground,--fuji-default,--fuji-focus-ring,
--fuji-background, …) existed only under[data-fuji-theme="…"]. Because
FujiProvideris optional (SPEC §2) and, withpersist, the scope wrapper
intentionally carries nodata-fuji-*until the bootstrap script or the mount
effect runs, those tokens resolved to nothing — a provider-less<Button>
painted transparent with inherited text color.:rootnow mirrors the
complete light set. The:roottone colors, which had drifted from the light
theme's (--fuji-earthwas#8b5e3cvs light's#b87c4c), are aligned too,
so a provider-less render is pixel-identical totheme="light".CommonJS consumers get CommonJS types. The
exportsmap had notypes
condition underrequire, sodist/index.d.ctswas built and published but
unreachable; arequire-mode TypeScript consumer resolved the ESM-flavoured
dist/index.d.tsinstead and could hit TS1479 undermoduleResolution: node16. Both conditions now declare their owntypes.Packaging and docs. Removed the duplicate
sourceMappingURLcomment tsup
8.5.1 emits for every file inbundle: falsemode (178 stray lines in the
tarball). Corrected the package name from@fuji-ui/reactto@fujiui/react
inTHIRD_PARTY_NOTICES.md(which ships) and across the contributor guides,
the packed-tarball filename indocs/migration.mdandCONTRIBUTING.md
(fujiui-react-<version>.tgz), and the component count inREADME.md
(79, not ~90). -
fafeb5f: Rework the visual system: surfaces are now defined by layered shadow rather
than by a visible border, the radius and elevation axes are visible decisions,
andglassis an Apple-Materials-style material instead of a translucent grey
rectangle. No component API changes - this is entirely token and recipe work,
so every component moves together. SeeDESIGN.mdfor the rationale.Surfaces. Light's page background moved from
#f7f6f2to#eceae6so a
white surface actually separates from it (two near-identical off-whites cannot
be told apart no matter the shadow). Borders dropped to a hairline (light: 10%
→ 6%) and shadows became three real layers instead of one or two faint ones.
Dark's surfaces were lifted (#191918→#1e1e1c) and lean on an inset edge
highlight, because on a near-black page a darker shadow communicates nothing.New
.fuji-raisedrecipe, applied bylib/appearance.tsto every
containedtone: a drop shadow plus an inner top highlight so the primary
action, active tab and checked box read as solid objects sitting on the
surface. New tokens--fuji-shadow-raisedand--fuji-raised-highlight, set
per theme and per elevation.Radius and elevation are now perceptible.
corneredis 6/10/14px and
softis 14/20/26px (was 8/12/14 and 13/18/22). A light card'sfloating
shadow goes to 48px max blur againstregular's 24px; dark goes to 60px
against 12px. Elevation still changes shadow depth only, never geometry.Glass. Blur and saturation went from 6-16px at 105-120% to 20/28/36/44px
at 160/200% across the four tiers, and the shipped atmosphere gained real hue
range, because blur over a flat grey gradient stays flat grey. Most
importantly the materials now tint dark (rgb(12 14 18)at 30/38/50/58%)
rather than white: a white tint pulls the panel toward a bright backdrop, and
white foreground text measured 3.2:1 over the warm region of the
atmosphere. Darkening makes the material's contrast independent of what it
sits on, which is what Apple's dark materials do. With a guaranteed-dark base
the text tiers could become a real scale again - they were 100%/90%/89% white,
which rendered as a single tier, and are now 100%/82%/71%. Worst measured
contrast across the whole shipped atmosphere is 7.66:1 foreground, 5.79:1
muted, 4.81:1 subtle.This step shipped glass as dark-tinted only, so over a light backdrop a
consumer supplied themselves, light-on-glass text would not meet AA.
DESIGN.mddocuments this and the light-tinted material that would fix it -
a light tint followed immediately after in the same release (briefly as its
ownglassTintoverride prop, later folded intothemeitself deciding the
tint with no separate axis at all - the override never reached a release).
The atmosphere itself also stopped being painted automatically later in this
release:--fuji-backgroundnow comes fromthemeinstead of glass's own
fixed canvas, and the gradient described above is an opt-in
.fuji-glass-atmosphereclass rather than something every glass root gets
for free. See the other changesets in this release anddocs/theming.md. -
fafeb5f: Motion system groundwork, and cut real runtime and CSS weight.
Motion. Added
--fuji-ease-spring(alinear()ramp that overshoots and
settles like a physical control, with no JS animation runtime) and
--fuji-duration-overlay; both collapse underprefers-reduced-motion.New
rippleprop onButton, for a pointer-origin press ripple. See the
material-ripple entry below for the behaviour that ships: it is on by default
on bothButtonandIconButton, andripple={false}opts a control out.
Fully disabled under reduced motion.Statisticno longer re-renders 60 times a second, and no longer renders
0on the server. The count-up wrote throughuseStateon every animation
frame - roughly 42 renders per visible tile, several per dashboard KPI row,
all to change one text node. It now writestextContentdirectly. That also
fixed a real bug: because state started at0, server-rendered and no-JS
output showed$0instead of the actual figure. The markup now contains the
final value and the effect rewinds only when it is about to animate.Removed
class-variance-authorityas a runtime dependency. It was used in
three files for what is a static lookup; those are now plain functions. The
rendered class attributes are byte-identical across all twelve appearance
combinations.Glass materials no longer apply to form controls.
field-surface.ts,
TextareaandOTPInputeach carriedfuji-glass-surface-strong, which put
a 36pxbackdrop-filteron every input on the page under the glass theme - a
twenty-field form meant twenty extra compositing layers for a blur nobody can
see behind a control that small. A representative page went from 4
backdrop-filtered elements to 3, with none on form controls.CSS isolation. Tailwind's
spin/ping/pulsekeyframes are emitted
under those bare global names and silently collide with a consumer's own
@keyframes spin; thefj:class prefix does not rename keyframes. Fuji now
declares its ownfuji-spin/fuji-ping/fuji-pulse, so every keyframe the
package ships is namespaced. The boot-time transition suppressor was also
scoped -html[data-fuji-boot] *killed every transition on the page,
including the consumer's own unrelated markup.New regression test (
src/styles/class-conflicts.test.tsx) asserting no
rendered element carries two classes from the same Tailwind conflict group.
This exists because removingtailwind-merge(an attractive ~10 kB saving)
was tried and reverted:NATIVE_CONTROL_RESETis applied first by 31
components and carriesborder-0/bg-transparent, which each component then
overrides later in the samecn()call. Without the merge pass both survive
and stylesheet order decides - which rendered everycontainedButton
transparent, with no existing test failing.cndocuments this so the saving
is not attempted again without first restructuring the reset. -
fafeb5f: One vocabulary for prop names, and two APIs that had no uncontrolled mode.
Breaking, under a minor bump (pre-1.0). Four props are renamed and one
default changes. SPEC.md §10 counts a rename and a changed default as breaking
and requires the callout even when the bump is minor, so: everySkeleton variant,Typography variant,Link color/hoverandContainer sizein a
consuming app has to be updated, and aCarouselthat relied on autoplay must
now ask for it.docs/upgrading.mdhas the before/after for each.Renames. Fuji uses
tonefor decorative color roles andvariantfor
semantic status. Four components predated that rule, which leftvariant
meaning three different things depending on which component you were reading:Skeleton.variant→shapeTypography.variant→scale(typeTypographyVariant→TypographyScale)Link.color→tone,Link.hover→hoverTone(typeLinkColor→LinkTone)Container.size→width-sizeeverywhere else in the package means a
control's height/padding scale, and Container's meant maximum line length
Carouselno longer autoplays by default.autoplaydefaults tofalse.
Self-starting motion lasting more than five seconds is a WCAG 2.2.2 obligation
for whoever ships the page, and defaulting it on handed every consumer that
obligation without telling them.CommandMenuworks uncontrolled.open/onOpenChangeare now optional
anddefaultOpenis available. It was the one component in the package that
forced auseStateon every caller, including its own stories.See
docs/upgrading.mdfor the full 0.2 → 0.3 diff. -
fafeb5f: Accessibility: state that was only a color is now also announced.
Navigation.
Navbar,BottomNavigationandSidebarmark their active
item witharia-current="page"(it was a background color and nothing else),
and all three name their landmark by default so a page with more than one
doesn't present a screen reader with identical "navigation" entries.Sidebar
now renders<nav>rather than<aside>- its items are links, so it is a
navigation landmark, not a complementary one - and its sections tie their
label to their items withrole="group"+aria-labelledby. ANavbaritem
with neitherhrefnoronItemSelectno longer renders as a hand cursor over
a hover highlight that does nothing when clicked.DataTablesorting. The header cell carriesaria-sort, the toggle's
accessible name states the direction ("Revenue, sorted ascending"), the arrow
glyphs arearia-hidden, and the body isaria-busywhile loading. Sort state
previously existed only as an arrow icon.Charts are one tab stop, not one per data point. A three-series,
twelve-point chart put thirty-six tab stops between the controls either side
of it, each announcing a value already present in the visually-hidden data
table every chart renders. A plot is now a single stop with arrow-key
navigation - left/right along a series, up/down between series, Escape to
release.Calendarday cells are named with the full date ("Saturday, March 14,
2026") instead of a bare day number, carryaria-current="date"on today, and
mark today with a dot as well as an accent color.Timelineitems announce theirvariantas a visually-hidden prefix
("Error: Deploy failed"); a newstatusLabeltranslates or suppresses it.ChatBubblekeeps the sender announced whengrouped- a sighted reader
infers it from the run's shape, a linear screen-reader pass had five
unattributed messages in a row.Dropzoneis named by purpose ("Upload files", overridable with the new
labelprop) rather than by whatever prosedescriptionheld, and its file
list is a named live region so a completed drop is announced.Dialogrenders its close button afterchildren, so initial focus lands
on the content rather than on the way out. Its position on screen is unchanged.Sidebar.Itemvalidateshrefthrough the same allowlist as every other
link in the package - it was the one component that passed ajavascript:URL
straight through. Rejected hrefs now warn in development builds instead of
silently rendering an inert anchor. -
fafeb5f: Second pass against the reference designs, component by component, with a
by-eye sweep of every story in light, dark, glass and glass-over-light.Toast stacks. A port of motion.dev's stacked notifications: toasts sit on
top of each other, newest in front, each one behind pushed up 10px, scaled
down 6% and faded 20%; a new toast springs in from below, a dismissed one
shrinks away; hovering the stack fans it out.Toastertakesposition.Coverflow is the reference's coverflow. The fan now follows the pointer
continuously while dragging (the track follows in every mode) and snaps on
release; neighbours rotate 20°, shrink to 70% and tuck under each other by
distance, and the edges fade out rather than clip.Progress springs. The linear fill is
transform: scaleX()on the spring
(motion.dev's loading bar), so chunked updates move as one elastic bar, and
it grows in from empty.CircularProgressis the thick, round-capped ring of
the reference:sizeaccepts a px diameter,thicknessoverrides the
tenth-of-diameter stroke, the percentage scales with the ring, and the arc
draws in.Bar chart as a dashboard card.
highlightmutes every other bar and tags
the emphasised one with its value and a pilled axis label;averagedraws a
dotted reference line;headline/stats/icon/actionsgive every chart
the card header of the reference. Bars are pill-shaped and grow from the
baseline; negatives hang below it.Donut gets a real tooltip (HTML, anchored to the hovered segment - the
SVG one was clipped to a sliver by the ring's 160-unit viewBox), a drop
shadow on the ring itself that deepens underfloating, and share
percentages in the legend. The two donut stories are one.Stepper follows the reference: filled circles with a popping check,
thick rounded connectors that fill right up to the current step, clickable
steps with adisabledoption, and a back/next playground.Timeline
groupsis the history layout: a year on a centred axis with a
dot, media on the left, dated entries on the right.Notification composes an activity inbox (
avatar,badge,media,
layout="inline") or alert cards.ChatBubble tails sit on the side facing the speaker, level with the
avatar; bubbles are white-on-shadow / raised black.Switch thumb is now inset equally at both ends (the track was
content-box, so its padding and border grew it past the thumb's travel).Glass over light scenes.
theme="light" material="glass"is the light
material: white-tinted surfaces, dark text. Two glass defects fixed on the
way: Toast painted dark text on the dark overlay (a stale colour swap from an
earlier, lighter tint), and default-tone progress fills and the first chart
series were near-black on the dark card (they use the foreground "ink" now).Radius
itemtier. Menu items, the checkbox box, kbd, tooltip and the
segmented-control indicator were pinned at 6px and ignoredsoft; they now
use--fuji-radius-item(6px / 10px).Also: unchecked Checkbox well gets a hairline inset so it reads on the page
background; ring track uses the stronger border; Notification titles wrap
instead of truncating; anAvatarphoto story. -
fafeb5f: New:
@fujiui/react/registry.json. A machine-readable description of this
package for tools that write code against it - editor agents, and the
documentation site. It carries what a prop table cannot: which category a
component belongs to and what it is for, its compound parts with both the
dot-access (Dialog.Content) and named-export (DialogContent) forms, whether
the file ships"use client", the design tokens per appearance scope, the
guides, and worked examples. Generated byscripts/build-registry.mjson every
build; the authored half lives inregistry/and does not ship. It consumes
props.jsonrather than re-deriving props, because two generators over one
surface is the drift it exists to prevent.props.jsonnow reports real defaults. It could only recover a default from
aDefault \x`phrase in a JSDoc comment, which most props never wrote - 62 of 416 had one, and two of those were the word "to", scavenged out of "Defaults to false, which...". Defaults are now read from the component's own destructuring (size = "md"), so a documented default is what the code does: 177 of 416, and none of them wrong. It also carriesextends(where a component's inherited props come from) anddeprecated`.Every prop is documented. JSDoc coverage went from 181 of 416 props to all 416.
Charthad 11 props and no descriptions;DatePicker2 of 14;Calendar
1 of 8;FileUpload,Pagination,EmptyStateandResultnone at all. This
reaches consumers three ways at once - the IDE tooltip viadist/index.d.ts,
the documentation site's prop tables, and the registry.Added
./props.jsonand./registry.jsonto SPEC's packaging section.
./props.jsonhad been in theexportsmap for some time while SPEC §3 and §7
still described three additional entries, so the artifact read as accidental.
Both are now named, with what they are and why they are not documentation.Registry components carry
title. The export name and the page name are not
always the same thing -IconButtonis documented as "Icon Button", and the
"Layout" page documents five separate exports - so consumers rendering a
component index need both.The registry describes its own vocabulary. Every prop whose type is a union
of string literals now carriesvalues(size->["sm","md","lg"]), and a
typesblock defines all 26 exported aliases once. Before this a consumer read
size: ComponentSizeand had nowhere to go - three of the most-used props in
the package are aliases, covering 74 props in all.Added
index,setupandtokens.groups.indexis the listing shape
precomputed: returning thecomponentsarray to answer "what components are
there" costs ~47k tokens against ~3.8k for the four fields the question
actually wants.tokens.groupslets a token lookup be sliced rather than
returning all ninety-nine.setupcarries per-framework install steps for
Next.js and Vite. Guides gainedpathandbytesinstead of inlined content -
docs/ships in the tarball, so a consumer reads a guide when it is asked for.Registry gains
exports. The full public export list, values and types
apart - 172 and 136 against the 88 catalogued components. A consumer checking
whether an import is real cannot answer that from the component catalogue: the
package also exports every*Rootvalue, every*Propsinterface, and item
types likeSelectItemandTreeNode. Answering from the short list reports
correct code as broken, which is worse than not checking at all.Compound parts now state where their props come from. 49 of 64 wrap a Base
UI primitive directly and declare no named*Propstype, so they were emitted
with an empty prop list - and "takes no props" is the one thing that is
certainly untrue of them. Each now carriesextends, and the generator warns
on a part that describes neither.Fixed: two shipped defaults were English scavenged out of prose.
Typography.aswas"follow"(from "defaults follow the variant's role") and
InfiniteScroll.loaderwas"spinner". The bare-word branch of the JSDoc
default regex is gone; a fallback default now has to be quoted, backticked, or
a literal. It is only a fallback in any case - 175 of 176 defaults are read
from the source destructuring.Fixed:
guides[].bytescounted UTF-16 code units, so three of the seven
guides under-reported their size. -
fafeb5f: Add
--fuji-surface-raisedand move every content-bearing fill onto it.Under dark glass
--fuji-surface-strongis a translucent white fill. That is
deliberate: bare, textless fills - Switch, Slider and Progress tracks - have
nothing but their own lightness to separate them from a near-black page, and at
a dark tint they measured 1.01:1 against it, the literal same pixel. But a white
tint lightens toward whatever is behind it, so every fill that carried text or an
icon lost contrast the moment glass sat over a bright backdrop.The two needs want opposite tint directions, so no single value serves both.
--fuji-surface-raisedis the content-bearing twin: dark-tinted under dark
glass, and identical to--fuji-surface-strongin every other block, so only
dark glass changes at all.dark glass, over the atmosphere before after AvatarGroup"+N"2.10:1 7.01:1 Paginationinactive tiles2.59:1 7.01:1 Dropzone/FileUploadrows2.26:1 8.20:1 Notification/EmptyStateicon tiles2.10:1 7.01:1 Icondefault/muted/subtle1.82-2.26:1 8.20:1 Icons are judged against WCAG 1.4.11's 3:1 floor, text against 4.5:1; every case
above now clears its own floor at both ends of the shipped scene.
--fuji-surface-strongis unchanged, so the bare fills it was tuned for keep
both their translucency and their 3:1 separation from the page.Also fixes
Dropzone's drag-over fill, which never painted in any theme.
scripts/css-entry.cssordersfuji.componentsafterfuji.utilities, so the
element's own.fuji-glass-surface-subtlebeat everyfj:bg-*utility
regardless of specificity. The state now drives a component-layer rule
(.fuji-dropzone[data-drag-active]), matching the pattern already used by
.fuji-chart-segment[data-active]- and the backdrop blur that class also
carries is preserved.Also fixes
ghostButton/IconButton at tonedefault, whose hover state used
the same bare-fill surface and so failed the same way: 2.26:1 over the
atmosphere's bright pixels, under even the 3:1 non-text floor. It now hovers on
--fuji-surface-raised, matching what the other four tones already did (each
hovers on its own-softtint). Ghost rests transparent, so any fill still reads clearly as hover feedback.Interactive rows that raise on hover (Sidebar, Calendar, NavigationMenu,
TimePicker, Stepper, ButtonGroup and Chart's legend) now pair that fill with a
hairline, via the sharedfuji-hover-raisedclass. Under dark glass the fill
alone could not do the job: on the darkest part of the atmosphere the panel
behind these rows is already near-black, so no darker fill separates from it
(measured 1.11:1, and no value improves it - there is no luminance left to
spend), while a lighter fill needs dark ink, which is what the SELECTED state
uses. An edge is luminance-independent, so it reads at any backdrop brightness,
and it is the device the glass surfaces already use. Hovered rows measure
1.93:1 against a resting row beside them, up from 1.11:1, with their text at
8.45:1 (previously ~2.7:1).Two dark-glass tokens moved again after measuring text inside a card rather
than on a bare panel. A card is itself a glass surface, so text in one
composites through two translucent layers and lands lighter than the same ink on
a single panel -Statistic's trend caption sat at 4.24:1 over the shipped
atmosphere, and--fuji-fireat 4.39:1, both under AA on the library's own
backdrop.--fuji-foreground-subtlegoes 71% -> 76% (4.60:1) and--fuji-fire
#ffb4b4->#ffbbbb(4.61:1). The stacked case, not the single-layer one, is
what these values have to satisfy.
Patch Changes
-
fafeb5f: Fixed: components overflowed their parent in any app without a global
box-sizingreset.This package deliberately ships no preflight, so a consumer with no reset of
their own gets the CSS default,box-sizing: content-box. Thirteen components
set an explicit size (width, height, ormin-width) and padding or a border on
the same element, which undercontent-boxputs that padding or border
outside the declared size -w-fullpluspx-8renders 64px wider than its
parent's content box.Containerwas the visible case: at a 1280px viewport itsxlwidth overhung
the page by its own gutter, producing a horizontal scrollbar. Measured in a
real browser, its computedbox-sizingwascontent-boxand its border box
was 1233px inside a 1169px parent. The other twelve (Sidebar,Toast,
Tabs,Tree,List,Slider,Calendar,ChatBubble,Carousel,
CommandMenu,MultiSelect,Kbd) are narrower and so were latent rather
than visible -Kbd's case is a border outside a declaredmin-widthrather
than padding outside a width, the same shape of bug on a smaller element.All of them now set
fj:box-borderexplicitly. A blanket
.fuji-theme-scope * { box-sizing: border-box }would have been shorter and
wrong: this stylesheet is imported globally, and that rule would restyle every
piece of a consumer's own markup nested inside the provider.src/styles/box-sizing.test.tsxasserts the invariant against rendered markup
and fails if any component sets a size and non-zero padding without it;Kbd's
border variant isn't shaped like that pattern and was checked by hand. -
fafeb5f: Fixed:
Carouselcould not be swiped when its slides were images or links.Those elements are natively draggable, so pressing one started the browser's
own drag-and-drop. That takes the pointer stream away from the page - Chrome
firesdragstartand thenpointercancelabout four pixels in - so the swipe
died almost as soon as it began and the carousel could only be driven by its
controls.Carouselnow cancels that native drag on the viewport; a child can
still opt back in with its owndraggable.The same
pointercancelwas also being treated as a completed swipe. It
carries no meaningful coordinate (Chrome reportsclientX: 0), which read as a
drag all the way to the viewport's left edge and threw the carousel several
slides forward instead of snapping back. A cancelled gesture now returns to the
current slide, and the distance is taken from the last offset actually applied
rather than from the event.Most visible with
effect="coverflow", where the fan is meant to track the
pointer continuously: because the gesture was cancelled before
data-swipinghad any effect, every slide kept its transition and the fan
lagged behind the pointer rather than following it. -
fafeb5f: Size the keycap shadow to the gap between caps.
.fuji-keycapdrew--fuji-shadow-control, the token for free-standing
controls. Atfloatingelevation that shadow reaches roughly 28px below the
cap, but caps sit 3-5px apart (--fuji-key-gapis fixed at everysize), so
almost all of it was painted underneath the neighbouring cap - invisible, and
rasterised once per cap on a board with up to 104 of them. The cost was
highest onmaterial="glass"withelevation="floating", the two largest
shadow tokens in the system.Caps now take a new
--fuji-shadow-keycap, a contact shadow that stays inside
the gap. It is keyed on theme only: a cap is recessed into its board rather
than floating above the page, so elevation belongs to the board, not the cap.
The rendered result is unchanged atregularelevation in the solid themes,
where the control shadow already fit; elsewhere only the occluded part is
gone. -
fafeb5f: The README has a new "AI coding agents" section introducing
@fujiui/mcp,
an MCP server that gives Claude Code, Codex and other agents the API of the
exact@fujiui/reactversion a project has installed - including the steps for
setting Fuji up in an existing app - and checks the code they write against
this package's conventions. A companionfuji-uiAgent Skill tells agents when
to use it, and a Claude Code plugin installs both at once
(/plugin marketplace add fuji-ui-kit/fuji-ui-react). The server reads the
dist/registry.jsonthis release adds, so it needs@fujiui/react0.3.0 or
later. -
fafeb5f: Fixed: a nested
FujiProvider radius="cornered"inside asoftapp rendered with
soft corners. Cornered values were only declared on:root, so a cornered scope
matched no radius rule and inheritedsoftfrom its ancestor.cornerednow has
its own[data-fuji-radius="cornered"]block, the same values as:root, so any
scope (a nested provider, or a portal re-stamping the attribute) can switch back
to it. -
fafeb5f: Fixed: the package did not compile against React 18, despite advertising it.
peerDependencieshas always said^18.0.0 || ^19.0.0, and the source has
real branches for the differences between the two - but every CI run and every
local install resolved React 19, so the 18 half of that claim had never once
been executed. It was broken:React.useRef<HTMLImageElement>(null)typescurrentas read-only under
React 18 (React 19 made it writable), soImage's ref callback failed to
compile. Declaring the ref as<HTMLImageElement | null>selects the mutable
overload, which exists in both versions.- Assigning through a forwarded
ref.currenthad the same problem, and is now
cast to a bare structural type that depends on neither version's ref
typings.
CI gains a
react18job that installs React 18 with--no-saveand reruns
typecheck and the full suite, so this cannot regress silently again. It
verifies the downgrade actually took effect before running anything - the
obvious version of that job passes against React 19 twice and reports green. -
fafeb5f: Three controls that silently ignored state they were supposed to render.
disabledhad no visual effect onCheckbox,Switch,RadioGroup,
NumberInput,Combobox, andMultiSelect. Base UI renders these as
<span role="checkbox">/<span role="switch">/<span role="radio">/
<div role="group">, with the realdisabledattribute sitting on a
visually-hidden<input>beside them, not on the element carrying Tailwind's
disabled:variant - a pseudo-class that can only match a genuine disabled
form control, so it never matched. Measured on a disabledCheckbox:
opacity: 1,cursor: pointer- pixel-identical to an active one. Base UI
does mirror the disabled state onto these elements asdata-disabled, so
Checkbox,Switch,RadioGroup, and the sharedlib/field-surface.ts
recipe (which is whatNumberInput's spin-button group and
Combobox/MultiSelect's input group render through) now use the
data-[disabled]:attribute variant instead -opacity: 0.45,
cursor: not-allowed.Input,Textarea, andNativeSelectshare the same
recipe but were never actually affected: they render through a real
<input>/<textarea>/<select>, wheredisabled:already matched.A field's red invalid border stopped painting when
invalidcame from an
ancestor<FormField invalid>instead of the field's owninvalidprop.
Input,NativeSelect,Textarea,NumberInput,Combobox,MultiSelect,
andSelectall wrotedata-invalid={invalid ? "" : undefined}- and a prop
explicitly set toundefinedstill occupies that key, which wins Base UI's
merge over thedata-invalidit had already computed from the ancestor
FormField. Sodata-[invalid]:border-fuji-firenever painted, and the
field's only error cue wasFormField's helper text. (aria-invalidwas
unaffected - it's recomputed in a later merge - which is why this went
unnoticed.) Fixed by omitting the attribute entirely when the localinvalid
prop is falsy, instead of asserting it toundefined, soFormField's value
passes through.IconButtonhad no focus-visible ring, falling back to the browser's
native outline (measured ~2:1 contrast against a light page). The
focus-visible classes lived inButton's own JSX rather than in the
button.styles.tsrecipe both components are built from, so the line was
never mirrored ontoIconButton. It now lives in the shared recipe, so
neither component can drift out of sync with it again. -
fafeb5f: Fixes from a one-by-one visual pass over every component in Storybook.
Removed the 1px inset "edge light" from raised objects. Every
contained
button, active nav item, checked control and selected tab carried
inset 0 1px 0 rgb(255 255 255 / 14%)on top of its drop shadow. At 1px with
no blur that is a hairline, and a hairline reads as a border on every theme -
not as light. Depth now comes from the cast shadow alone, which is what the
reference designs do. The same 1px inset line was also baked into every dark
and glass surface shadow (--fuji-shadow-inset-highlight, on cards, tables,
charts, calendars) and is removed there too - glass surfaces already carry a
real hairline border, so the inset line was a second border drawn just inside
the first.Links no longer inherit the browser's underline and blue. This package
ships no preflight, so a bare<a href>keeps the UA stylesheet's underline
and-webkit-linkcolour - and because the underline is painted by the anchor
itself, a coloured<span>inside it does not hide it.Navbarrendered grey
labels on bright blue underlines;Breadcrumband everySidebar.Itemwere
underlined;Button asChildonto a link was underlined. A shared
NATIVE_LINK_RESETnow sits next toNATIVE_CONTROL_RESETand is applied to
every Fuji-styled anchor (Linkmanages its own underline by design).ghostbuttons no longer cast a shadow. Button and IconButton's shared
base appliedshadow-fuji-controlto every appearance, so a transparent ghost
control rendered as a faint box. Ghost is nowshadow-none.ButtonGroup's outline was far heavier than the bordered Button beside
it. Each segment kept its own shadow (the selected one its raised shadow)
inside anoverflow-hiddengroup, piling shadow against every divider. The
group carries one shadow; segments carry none.Statisticcould display a negative number. A rAF timestamp is stamped at
the start of its frame, which can precede theperformance.now()read the
count-up took as its start time - so the first tick computed negative
progress, the cubic ease went negative with it, and the tile showed "-1,292".
Normally one frame; under rAF throttling, the frame that stays on screen.
Progress is clamped at both ends and the start time comes from the first
frame itself.Statistic's trend line had the browser's default paragraph margins.DropdownMenu.GroupLabelwas a bare re-export of the Base UI primitive -
body-sized, foreground-coloured, flush to the popup edge. It is styled like
every other group heading in the package now.BottomNavigationgains apositionprop and defaults tosticky. It
was hard-coded toposition: fixed, which pins to the viewport regardless of
where it is rendered - it escaped every container it was placed in, and could
not be overridden viaclassName, because Fuji's positioning class lives in
its own cascade layer and wins over a consumer's utility.stickypins to the
bottom of its own scroll container (the page when rendered at the end of
<body>, a panel when rendered inside one);position="fixed"restores the
old behaviour,"absolute"suits aposition: relativeparent.FormField.Description,FormField.ErrorandCodeBlock's<pre>kept
the browser's default paragraph margins.