fix: Design audit — motion, states, focus & craft across the library#853
Closed
ravisuhag wants to merge 41 commits into
Closed
fix: Design audit — motion, states, focus & craft across the library#853ravisuhag wants to merge 41 commits into
ravisuhag wants to merge 41 commits into
Conversation
h1-h3 all used --rs-line-height-t1 (24px) regardless of font size, so a 32px h1 overlapped its own wrapped lines. Point each heading at its matching t4/t3/t2 line-height; h4 already paired correctly. Plan: plans/001-heading-line-heights.md
A later transition:width rule on .root, .nav-item, .nav-text, and .resizeHandle replaced (not merged with) each element's own transition, silently killing nav-item hover/press fades, the nav-text opacity fade, and the resize-handle hover fade for every motion-enabled user. Scope the width transition to .root, the only element that actually resizes. Plan: plans/002-sidebar-width-transition-scope.md
…s tier --rs-transition-interactive hardcoded 0.2s ease instead of composing the duration/easing tokens declared a few lines below it, and applied the same 200ms to :active — so a button press arrived as slowly as a hover fade. Compose the token, add --rs-duration-press (100ms) and --rs-transition-pressed, and wire it into button/icon-button :active. Also replace every hardcoded duration/easing across 14 component stylesheets with the matching --rs-duration-*/--rs-ease-* token, and sync tour's FADE_OUT_MS (160, matching nothing) to 150ms, the actual .spotlightCover fade duration. Plan: plans/003-motion-tokens-and-press-tier.md
Tracking ran backwards (16px large carried more spacing than 14px regular) and titles had zero tracking at every size. Leading was non-monotonic too (mini looser than small; t1 tighter than every larger title). Re-step both ramps: body tracking shrinks toward neutral as size grows, titles step progressively negative in em; body leading rises smoothly to the 16px peak, title leading falls as display size grows. NEEDS VISUAL QA: this reflows all text across every consuming app. Requires a designer eyeball pass in apps/www (docs page, data-table page, t1-t4 headings) before this ships to production, per the plan's verification section. Plan: plans/006-typography-tracking-leading.md
…nents :active either duplicated :hover or didn't exist, so pointer-down gave no acknowledgment before an action's result appeared. Add a pressed vocabulary: one shade darker background where the hover ladder allows it, otherwise a subtle scale/stretch (button 0.97, checkbox/radio 0.92, switch thumb stretch, chip dismiss 0.9, slider thumb grow+lift). Also fold in the slider part of audit #55: delete a dead .thumb:hover { border-color } rule and add grab feedback on the thumb children (position itself stays transition-free for 1:1 drag). Plans: plans/004-pressed-states-pass.md
Many controls stripped the outline (all: unset, outline: none/0) with nothing in its place, leaving keyboard users unable to see where focus is: tabs, collapsible, checkbox, radio, switch, toggle, slider thumb, color-picker thumbs, icon-button, link, borderless input/text-area, chip/filter-chip dismiss buttons, number-field, code-block's floating copy button, and breadcrumb links/dropdown trigger. Apply three house treatments by element type: an accent ring for controls (upgrading the existing faint 1px button ring to 2px with offset), a subtle background for nav-style breadcrumb items (the sidebar pattern from #808), and a soft box-shadow ring for chromeless text-entry surfaces. Also split accordion's focus from its hover (audit #52) and add otp-field's soft ring on top of its border shift (audit #53/#58). Plans: plans/005-focus-indicators-pass.md
Small checkbox/radio (12px), icon-button sizes 1-2 (12/16px), toggle sizes 1-2 (12/16px), and the slider's small thumb (8x16px) all render below the 24x24px WCAG 2.5.8 minimum. Add an invisible, absolutely positioned ::before on each that pads the hit box to 24x24px; visible rendering and layout are unchanged. Plan: plans/018-hit-targets.md
The reset setTimeout was never cleared, so rapid clicks stacked timers and the check icon could revert early; it also fired after unmount. Hold the timer in a ref, clear/restart it per click and on unmount. Replace the boolean copied flag with an idle/copied/error status. Stack all three icons and cross-fade via opacity (plus a subtle scale under motion) instead of a hard swap, and add a visually hidden role="status" region announcing "Copied"/"Copy failed". CopyButton now always mounts CheckCircleFilledIcon and CrossCircleFilledIcon (previously only Check rendered, conditionally). Both are SVG-asset icons that only transform via the rollup build's @svgr plugin, not under vitest, so two consumer tests (code-block, color-picker) that render CopyButton needed their icon mocks extended to cover the new icon — same pattern code-block's test already used. Plan: plans/007-copy-button.md
ordering.tsx referenced styles['display-popover-sort-icon'], a class that never existed in data-table.module.css (it only lived in data-view's module), so the icon rendered unsized. Add the class. Also replace the two-icon swap (TextAlignBottomIcon <-> TextAlignTopIcon) with a single icon rotated 180deg on sort direction, in both data-table and data-view's identical ordering component — reads as one state flipping instead of the icon being replaced. Plan: plans/008-data-table-sort-icon.md
- toolbar.module.css: background-color: none is not valid CSS and was silently dropped; use transparent. - text: the strikeThrough+underline compound variant class was named text-italic-strike-through but applies no italic (it merges the two decoration lines so they don't cancel out). Rename to text-underline-strike-through rather than adding italic, which would wrongly italicize any underlined+struck-through text. Plan: plans/009-misc-css-fixes.md
value={null} (indeterminate) rendered a frozen empty track since
nothing styled it. Add a static dimmed fallback plus a looping sweep
(linear) / rotation (circular) gated behind prefers-reduced-motion,
keyed off Base UI's data-indeterminate attribute.
Also move both Progress and Meter's linear fill from a transitioned
width to transform: scaleX(), driven by the existing
--rs-progress-percentage / --rs-meter-percentage variables — the
indicator now renders full-width and Base UI's inline width is
neutralized by an explicit style prop, so value changes composite
instead of triggering layout.
Plan: plans/010-progress-meter.md
The input had zero focus, hover, or invalid styling -- a focused field looked identical to an idle one. Mirror Field's states verbatim (hover/focus border+background, invalid border) and add transitions to both the input and the steppers, which previously snapped instantly. Stepper :active and :focus-visible already landed via the library-wide passes (plans 004/005); this plan's remaining gap was the missing transitions plus the input's hover/invalid states and the input:focus background-color. Plan: plans/011-number-field-states.md
…uced motion The popup kept its 450ms transform transition active while swiping, so it eased toward the finger instead of tracking it directly (the backdrop already zeroed its own transition). Add transition: none on [data-swiping]. The five swipe-strength exit calcs hardcoded 400ms while enter runs 450ms via --rs-duration-drawer -- non-swipe exits ran measurably faster than opens for no reason. Use the token so enter/exit match and retuning the drawer duration doesn't require finding five literals. The panel had no transition outside the motion media query, so under prefers-reduced-motion it popped open/closed instantly. Add a base opacity fade (with matching [data-starting-style]/[data-ending-style] opacity: 0) so reduced motion gets a fade instead of a teleport, and move the per-side slide transforms into the no-preference block alongside the transition they animate. Plan: plans/012-drawer-swipe-and-fades.md
Four independent gaps: - Toast starting-style set a transform but no opacity: 0, so under reduced motion (where the transform never transitions) toasts popped in at full opacity instead of fading. - Navbar's hide-on-scroll transform had no reduced-motion fallback at all, so the bar teleported in/out; add an additive `reduce` block that fades it instead. - Spinner poles had no static opacity, so reduced motion (where the spin animation never applies) rendered a static asterisk instead of a graded ring that still reads as loading. - Dialog and command's backdrop blur had no prefers-reduced-transparency guard; add one with a heavier solid scrim (alert-dialog shares dialog's stylesheet, so it's covered too). Plan: plans/013-reduced-motion-and-transparency.md
Tooltip animated with one-shot @Keyframes: not interruptible (restarts from zero instead of retargeting), no exit at all (popup unmounted instantly), a dead unused fade keyframe, and a dead transform-origin (every keyframe was a pure translate, origin-independent). Replace with [data-starting-style]/[data-ending-style] transitions at --rs-duration-fast/--rs-ease-out, keeping the exact per-side and per-align 2px directional offsets the keyframes used. Enter and exit now mirror each other, rapid hover toggling retargets smoothly instead of restarting, data-instant still skips the animation via transition-duration: 0, and reduced motion gets an opacity-only fade (previously nothing, since every rule lived inside the no-preference media query). Plan: plans/014-tooltip-transitions.md
Uncontrolled dismiss returned null on the next render, so the callout vanished in one frame and content below it snapped up by its full height. There was no enter transition either. Wrap the callout in a transition shell driven by a data-state attribute (open/closing/closed). Dismiss now plays a 200ms fade + grid-row height collapse before unmounting, so content below glides up instead of snapping. Mount fades in over the same duration. Reduced motion is instant both ways. The controlled API (onDismiss provided) is unchanged -- the consumer still owns removal. Plan: plans/015-callout-enter-exit.md
.nav-text declared an opacity transition, but the collapsed state set display: none, which can't transition and short-circuited it -- labels popped out of existence while the frame animated its width. Same for any consumer content marked data-collapse-hidden. Replace display:none with opacity + visibility (visibility keeps collapsed content out of the accessibility tree and unfocusable, same intent as display:none, but participates in the transition). Also fix the resize handle's cursor: it only click-toggles collapse, but showed ew-resize, promising a drag that doesn't exist. Use pointer on both left- and right-positioned sidebars (per the audit's explicit decision not to implement drag-to-resize). Plan: plans/016-sidebar-collapse-fades.md
…es in The 2D area thumb was positioned via left/top on every pointermove, forcing layout on each frame of a drag. Drive it from two CSS custom properties (--thumb-x/--thumb-y, unitless 0..1 fractions set from JS) composed with container query units and the existing -50% self-centering transform -- compositor-only, no transition on transform so the drag stays 1:1 with the pointer. Also add touch-action: none / user-select: none to .selectionRoot (the sliders already had this; the 2D area didn't, so dragging on touch also scrolled the page), and a fast opacity transition on the thumb so its first-render reveal fades instead of popping. Plan: plans/017-color-picker.md
Field's error message popped in with no transition and unmounted the description in the same frame -- a double layout jolt right when the user needed to read what went wrong. Keep the description mounted alongside the error, and animate the error's appearance with a grid-row height-expand + fade (@starting-style, 150ms, instant under reduced motion). Disappearing stays instant -- only the appearance animates. DatePicker set an "Invalid date" error but only ever surfaced it as aria-invalid (a border tint) -- the message itself was never shown. Render it as reserved-space helper text below the default input, styled like Field's own error text, announced via role="alert". Plan: plans/019-field-validation.md
A chip with onClick rendered as a <span onClick>: no keyboard activation, not in the tab order, no focus ring, and cursor: default even though it was clickable. Render a <button type="button"> when onClick is passed and the chip isn't dismissible (a button containing the dismiss button would be invalid nested-interactive HTML, so dismissible chips keep the span). Interactive chips get cursor: pointer, inherit the app font, and a focus-visible accent ring. Plan: plans/020-chip-interactive.md
…otation The select positioner painted an opaque background behind the 0.97-scale popup, so a square flash of it stuck out past the shrunken popup corners during enter/exit. Drop the background, matching combobox's positioner. Multi-select options nested a real, focusable Checkbox inside an element with role="option" -- nested interactives confuse screen readers and add a second tab stop inside the listbox. Replace it with a non-interactive, aria-hidden span that reproduces the checkbox's exact resting visuals; selection is already conveyed by aria-selected on the option itself. Neither the select trigger's nor the combobox input's chevron rotated on open. Add a 180deg rotation keyed off Base UI's data-popup-open (select trigger directly; combobox needs a :has() hook since data-popup-open lands on the sibling <input>, not an ancestor of the chevron), reduced-motion-gated. Plan: plans/021-select-combobox.md
…bled cursor Hovering an off toggle looked identical to the on state -- the control lied about whether it was selected. Give hover a lighter tint and pressed (Base UI's toggled-on state) a deeper, inset-shadowed look in both standalone and group mode; hovering an already-on toggle now deepens it one more step instead of reverting to the hover tint. The content background and inset shadow had no transition at all (only color did), so state changes snapped. Add a fast-tier transition for both. pointer-events: none on disabled toggles suppressed the declared cursor: not-allowed, so disabled toggles gave zero affordance. Restore pointer-events so the cursor shows, with explicit hover neutralizers so a disabled-but-off toggle doesn't pick up hover styling (clicks stay blocked natively via the disabled <button>). Plan: plans/022-toggle-states.md
Row selection was wired up in data-table and data-view-beta (both set
data-state="selected") but no CSS anywhere styled it -- selecting a
row changed nothing on screen. Add an accent-subtle background for
selected rows (wins over hover/pressed) and a one-step-darker pressed
background for clickable rows, instant (no transition, matching the
house rule for direct-manipulation highlights like menu items).
Virtualized cells painted an opaque background that covered the row's
color entirely -- add `.virtualRow .virtualCell { background: inherit }`
so selection/hover/press show through in virtualized mode, same as the
non-virtualized cells already do.
data-view (no selection support) gets the same pressed-row treatment.
The bare Table primitive had no row-state hooks at all. Table.Row now
accepts an `interactive` prop (cursor + hover + pressed) and responds
to a consumer-set data-state="selected", with cells inheriting the row
background through the opaque .cell rule.
Plan: plans/023-data-row-states.md
The floating copy button had no transition of its own duration (it inherited the interactive tier's opacity fade), so its reveal moved at hover-feedback speed instead of a dedicated reveal timing. Re-compose the IconButton base transition and override just the opacity entry with --rs-duration-fast/--rs-ease-out. The :focus-visible reveal rule (added by the earlier focus-indicators pass) is merged into the same selector as the hover reveal for clarity. Plan: plans/024-code-block-copy.md
Proportional digits meant amounts jittered as values changed and misaligned in table columns. Add a CSS module with font-variant-numeric: tabular-nums as the default; className/style still merge through as the opt-out escape hatch. Plan: plans/025-amount-tabular-nums.md
Dimming a link to 80% opacity on hover is a weak signal and makes the text harder to read at the exact moment the user is targeting it. The underline geometry now always exists (text-decoration-color: transparent at rest) so hover is a pure color fade to currentColor -- zero layout shift, works for every Text color variant automatically. Press gets a brief variant-agnostic dim (:active, opacity 0.7) instead of a hardcoded color, since a fixed active color would clobber non-accent variants. Timing moves from a hardcoded 0.2s to --rs-duration-fast. Plan: plans/026-link-states.md
Resting day cells used radius-2 while hover/selected/range-cap states all used radius-5, so sweeping the pointer across the grid made each cell's shape pop between rounded-square and pill. The radius-2 resting state was never actually visible (the cell's background matches the calendar root's), so unify on radius-5 -- zero visible states change, one invisible one gets fixed. Hover/selection highlights stay instant, per the audit's explicit rejection of transitions on direct-manipulation surfaces. Loading hard-swapped the whole table for a differently-sized Skeleton in one frame, flashing on fast loads and jumping the popover height. Keep the table mounted and overlay the skeleton, cross-fading via opacity/visibility (~150ms, instant under reduced motion) so the grid never disappears and the height never moves. Plan: plans/027-calendar-polish.md
Sticky headers and group anchors in table, data-table, and data-view drew an always-visible hairline even at rest (scroll position 0), when nothing is actually floating over anything yet. Use CSS scroll-driven animations (animation-timeline: scroll(), gated behind @supports) to fade the hairline in across the first 24px of scroll -- it tracks scroll position 1:1 as state, not time-based motion, so it stays active under prefers-reduced-motion and degrades to today's static edge in browsers without support. data-table's non-virtualized .stickySectionHeader is deliberately left alone: its many instances stick at different per-element scroll offsets that a single scroll() timeline can't express (documented in a comment; @container scroll-state(stuck: top) is the future fix once support matures). data-view-beta is untouched per the audit's Skip decision on its doubled divider. Plan: plans/028-sticky-scroll-edges.md
The positioner transitioned top/left/right/bottom and the popup transitioned width/height on every open -- but Base UI only writes inset positioning and drives --popup-width/--popup-height when a PreviewCard.Viewport child triggers its adaptiveOrigin mode. In the common case (no Viewport), Base UI positions purely via transform, so the four inset transitions were dead weight forcing unnecessary transition-list overhead on every reposition. Split into two rulesets: the default path transitions transform + opacity only (compositor-only), while `:has(.viewport)` keeps the inset/size transitions where they're the actual morph mechanism. Plan: plans/029-preview-card-transforms.md
Hovering the scrollbar grew its width/height from 4px to 6px, reflowing the viewport content every hover (the scrollbar is a real flex sibling of the viewport). Keep the track a constant 6px (transparent, so the extra gutter is invisible) and scale the thumb itself between 0.667 and full size, anchored to the outer edge -- compositor-only, no layout, content no longer shifts. The thumb also never changed color on hover or drag, giving no acknowledgment that it was grabbed. Add one overlay step per state (a5 resting -> a6 hover -> a7 pressed via :active, since Base UI exposes no data-pressed attribute for this component). Plan: plans/030-scroll-area-thumb.md
overflow: scroll forced a permanently visible scrollbar gutter on platforms with classic scrollbars, even when content didn't overflow. Use auto. Also document via JSDoc that SidePanel intentionally has no enter/exit animation -- it's persistent layout chrome like Sidebar, not an overlay. Plan: plans/031-side-panel-overflow.md
Popover -- a larger floating surface than the tooltip -- cast a lighter shadow (--rs-shadow-soft) than the tiny tooltip (--rs-shadow-lifted), so elevation read backwards when both were on screen. Match menu, a peer surface of similar size, which already uses --rs-shadow-lifted. Plan: plans/032-popover-shadow.md
Hover color snapped instantly instead of easing like every other interactive element in the library. Add a fast-tier color transition. The active crumb's font-weight: medium was a no-op -- the root already set the whole breadcrumb to medium, so every crumb rendered identically. Drop the root to regular so the active item's medium weight (plus its existing color difference) actually makes the current page stand out. Plan: plans/033-breadcrumb-polish.md
…hiding it Both variants hid the shared Tabs.Indicator and painted the active state directly on the trigger, so switching tabs snapped instantly instead of gliding like the default segmented variant. The indicator already tracks the active tab via Base UI's --active-tab-* variables for free -- hiding it threw that away. Restyle the indicator per variant instead: standalone becomes the active chip fill (background + border) gliding between tabs, with the active trigger going transparent so the pill shows through (the same trick the segmented variant already uses); plain becomes a 1px underline sliding along the bottom edge, positioned via the trigger's own --active-tab-height so it lines up with the reserved border-bottom exactly. Both inherit the indicator's existing --rs-duration-moderate/--rs-ease-in-out transition, gated behind reduced motion, with no new media query needed. Plan: plans/034-tabs-variant-indicator.md
The action button -- the bar's only interactive element -- had no hover, active, or focus-visible styles at all. Add opacity-step hover/press feedback and a currentColor focus ring (the house accent ring would vanish against the bar's colored backgrounds). Add an opt-in dismissible prop mirroring Callout's controlled/ uncontrolled pattern: dismissible shows a close IconButton; onDismiss makes removal consumer-owned (bar stays mounted, collapsed) or the bar removes itself. Exit is a grid-row height-collapse + fade over --rs-duration-normal, instant under reduced motion, so content below glides up instead of snapping when the bar disappears. Plan: plans/035-announcement-bar.md
A display:block heading already fills its container in normal flow; the explicit width:100% only mattered in flex/grid contexts, where it made a heading claim the full row/track and push siblings around. Ellipsis truncation is the only variant that needs an explicit width, and it already declares its own -- removing the base rule loses nothing there. BREAKING (mild): consumers relying on the base width:100% inside flex/ grid parents (e.g. a centered heading, or a heading used as a full-row flex child) will see it shrink to content width. One-line fix on their side: set width:100% via className where the old behavior was intentional. Plan: plans/036-headline-width-scope.md
…dius Fallback initials used fixed body-text tracking (+0.5px) and a fixed 24px line-height at every avatar size -- visibly gappy at large sizes (36px initials in a 120px avatar) where display type wants neutral to negative tracking, and simply wrong at both extremes. Add a --fallback-letter-spacing custom property per size variant (same pattern as the existing --fallback-font-size), stepping from +0.05em at the smallest size to -0.01em at the largest; line-height moves to a unitless 1 since flex centering already handles vertical alignment. .avatar-full used !important only to beat the size variants' own border-radius at equal specificity. Use .avatar.avatar-full (two classes) instead -- both classes always co-occur, so it wins regardless of source order without needing !important. Plan: plans/037-avatar-fallback-typography.md
The error handler swapped src to the fallback with no re-entry guard -- if the fallback URL itself 404s, onError fires again and re-assigns the same failing src, looping requests indefinitely. Track a one-shot ref so the fallback is only ever assigned once; a second failure falls through to showing the alt/broken rendering instead. Both Image and Avatar's photo popped in abruptly with no load transition. Image now tracks load state via a useLayoutEffect that checks node.complete on mount/src-change -- already-decoded (cached or SSR-painted) images stay visible with no fade; genuinely loading images fade in over 150ms on load. Avatar's image only mounts once Base UI's probe reports loaded, so its fade is a one-shot keyframe animation, skipped entirely for cache-fast loads (<100ms between loading and loaded) so a quick load isn't artificially delayed. Reduced motion shows both instantly at full opacity, never invisible. Plan: plans/038-image-load-fade-and-guard.md
Escape/programmatic close used to snap the spotlight dim away instantly; now it holds the last-painted hole and fades it out over FADE_OUT_MS (150ms, matching --rs-duration-fast), mirroring the enter animation. A mid-tour orphan (target unmounts while still active) still hides instantly with no fade, and no longer leaves a stale frame behind for a later real close to replay. Ref: plans/039-tour-overlay-exit.md
Centering used translateX(-50%), which would collide with any transform-based entrance. Move centering to inset+margin-inline:auto (safe since the root is width:fit-content) so transform is free for motion. Add a fade-in for everyone, plus an 8px rise from the anchored edge under prefers-reduced-motion: no-preference. Exit stays instant (no mount controller exists to hold the node past removal). Ref: plans/040-floating-actions-enter-exit.md
A slightly faster spinner raises perceived load speed. Recomputed all 8 pole animation-delays to keep the same 1/8-cycle stagger ratio at the new duration, so the sweep still rotates evenly. Ref: plans/041-spinner-speed-feel-check.md
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
1 task
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design audit — library-wide motion, states, focus & craft
This branch implements the approved fixes from a design audit of every
component in
@raystack/apsara. 41 source-only commits, all underpackages/raystack/.Stacked on
improve-animations(this PR is based on that branch), so the diffhere is just the audit work.
What changed, by phase
scope, motion tokens + a fast press tier, monotonic typography tracking/leading.
:activepressed states,:focus-visiblerings, and 24px minimum hit targets across the library.
icon, indeterminate Progress, number-field states, drawer swipe/fades,
reduced-motion & reduced-transparency fallbacks, tooltip/callout motion,
select/combobox, toggle, table row states, code-block copy, link states,
calendar, sticky headers, and more.
popover shadow, breadcrumb, tabs indicator, announcement bar, headline width
scope, avatar/image, tour overlay exit, floating actions, spinner speed.
Notes
apps/playground), the implementationplans, and the audit report are intentionally not included — they are
local working tools, not part of this change.