From b85317e996b4151dfaea1bf98451a4baf2b6448e Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:33 +0530 Subject: [PATCH 01/61] fix: consolidate motion and interaction tokens Compose the shared easing, duration, pressed-scale and focus-ring tokens so components stop hand-rolling their own timings and curves. --- packages/raystack/styles/effects.css | 127 +++++++++++++++++++++------ 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/packages/raystack/styles/effects.css b/packages/raystack/styles/effects.css index 2ebecd2ab..836ce251d 100644 --- a/packages/raystack/styles/effects.css +++ b/packages/raystack/styles/effects.css @@ -1,35 +1,112 @@ :root { - /* Shadows */ - /* Example usage: box-shadow: var(--rs-shadow-feather); */ - --rs-shadow-feather: 0px 1px 1px 0px oklch(0 0 0 / 0.06), 0px 4px 4px -1px oklch(0 0 0 / 0.02); /* sm */ - --rs-shadow-soft: 0px 2px 4px 0px oklch(0 0 0 / 0.04), 0px 1px 2px 0px oklch(0 0 0 / 0.04); /* md */ - --rs-shadow-lifted: 0px 1px 1px 0px oklch(0 0 0 / 0.07), 0px 2px 5px 0px oklch(0 0 0 / 0.07), 0px 3px 8px 0px oklch(0 0 0 / 0.07); /* lg */ - --rs-shadow-floating: 0px 1px 1px 0px oklch(0 0 0 / 0.04), 0px 2px 8px 0px oklch(0 0 0 / 0.04), 0px 3px 17px 0px oklch(0 0 0 / 0.04), 0px 4px 30px 0px oklch(0 0 0 / 0.13); /* xl */ - --rs-shadow-inset: 0px 1px 1px 0px oklch(0 0 0 / 0.04) inset; - - /* Transitions */ - --rs-transition-interactive: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; - - /* Blurs */ - /* Example usage: backdrop-filter:: var(--rs-blur-sm); */ - --rs-blur-sm: blur(0.5px); - --rs-blur-md: blur(1px); - --rs-blur-lg: blur(2px); - --rs-blur-xl: blur(4px); + /* Shadows */ + /* Example usage: box-shadow: var(--rs-shadow-feather); */ + --rs-shadow-feather: + 0px 1px 1px 0px oklch(0 0 0 / 0.06), 0px 4px 4px -1px oklch(0 0 0 / 0.02); /* sm */ + --rs-shadow-soft: + 0px 2px 4px 0px oklch(0 0 0 / 0.04), 0px 1px 2px 0px oklch(0 0 0 / 0.04); /* md */ + --rs-shadow-lifted: + 0px 1px 1px 0px oklch(0 0 0 / 0.07), 0px 2px 5px 0px oklch(0 0 0 / 0.07), 0px 3px 8px 0px oklch( + 0 0 0 / 0.07 + ); /* lg */ + --rs-shadow-floating: + 0px 1px 1px 0px oklch(0 0 0 / 0.04), 0px 2px 8px 0px oklch(0 0 0 / 0.04), 0px 3px 17px 0px oklch( + 0 0 0 / 0.04 + ), 0px 4px 30px 0px oklch(0 0 0 / 0.13); /* xl */ + --rs-shadow-inset: 0px 1px 1px 0px oklch(0 0 0 / 0.04) inset; + + /* Transitions */ + --rs-transition-interactive: + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out), + color var(--rs-duration-normal) var(--rs-ease-out), + box-shadow var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); + /* Pressed states: acknowledge pointer-down near-instantly. Apply on + `:active`; releasing falls back to --rs-transition-interactive (200ms). */ + --rs-transition-pressed: + background-color var(--rs-duration-press) var(--rs-ease-out), + border-color var(--rs-duration-press) var(--rs-ease-out), + color var(--rs-duration-press) var(--rs-ease-out), + box-shadow var(--rs-duration-press) var(--rs-ease-out), + opacity var(--rs-duration-press) var(--rs-ease-out), + transform var(--rs-duration-press) var(--rs-ease-out); + + /* Pressed scale: a control shrinks slightly under the finger. Smaller + targets need a larger delta to read, so there are two steps. */ + --rs-scale-pressed: 0.97; /* buttons, tabs, larger controls */ + --rs-scale-pressed-strong: 0.92; /* checkbox, radio, chip dismiss */ + + /* Focus ring: keyboard-focus indicator. The shadow variant is for + chromeless text-entry surfaces that have no border to ring. */ + --rs-focus-ring: 0.5px solid var(--rs-color-border-accent-emphasis); + --rs-focus-ring-shadow: 0 0 0 1.5px var(--rs-color-border-accent-primary); + + /* Ring offset: rings sit flush (0) by default. A control adds one of these + in its own :focus-visible rule when it needs to — accent-filled controls + push the ring out so it clears the same-colour fill; controls in a + clipped container pull it in so the ring isn't cut off. */ + --rs-focus-ring-offset-accent: 2px; + --rs-focus-ring-offset-inset: -2px; + + /* Motion */ + /* Example usage: transition: opacity var(--rs-duration-normal) var(--rs-ease-out); */ + --rs-ease-out: cubic-bezier( + 0.22, + 1, + 0.36, + 1 + ); /* entrances, exits, value changes */ + --rs-ease-in-out: cubic-bezier( + 0.77, + 0, + 0.175, + 1 + ); /* elements moving on screen */ + --rs-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer slide */ + --rs-duration-press: 100ms; /* pressed/active feedback */ + --rs-duration-fast: 150ms; /* tooltips, menus, small popups */ + --rs-duration-normal: 200ms; /* popovers, dialogs, switches */ + --rs-duration-moderate: 250ms; /* tabs indicator, preview card, tour steps */ + --rs-duration-slow: 400ms; /* toasts */ + --rs-duration-drawer: 450ms; /* drawer */ + + /* Blurs */ + /* Example usage: backdrop-filter:: var(--rs-blur-sm); */ + --rs-blur-sm: blur(0.5px); + --rs-blur-md: blur(1px); + --rs-blur-lg: blur(2px); + --rs-blur-xl: blur(4px); } [data-theme="light"] { - --rs-shadow-feather: 0px 1px 1px 0px oklch(0 0 0 / 0.06), 0px 4px 4px -1px oklch(0 0 0 / 0.02); - --rs-shadow-soft: 0px 2px 4px 0px oklch(0 0 0 / 0.04), 0px 1px 2px 0px oklch(0 0 0 / 0.04); - --rs-shadow-lifted: 0px 1px 1px 0px oklch(0 0 0 / 0.07), 0px 2px 5px 0px oklch(0 0 0 / 0.07), 0px 3px 8px 0px oklch(0 0 0 / 0.07); - --rs-shadow-floating: 0px 1px 1px 0px oklch(0 0 0 / 0.04), 0px 2px 8px 0px oklch(0 0 0 / 0.04), 0px 3px 17px 0px oklch(0 0 0 / 0.04), 0px 4px 30px 0px oklch(0 0 0 / 0.13); + --rs-shadow-feather: + 0px 1px 1px 0px oklch(0 0 0 / 0.06), 0px 4px 4px -1px oklch(0 0 0 / 0.02); + --rs-shadow-soft: + 0px 2px 4px 0px oklch(0 0 0 / 0.04), 0px 1px 2px 0px oklch(0 0 0 / 0.04); + --rs-shadow-lifted: + 0px 1px 1px 0px oklch(0 0 0 / 0.07), 0px 2px 5px 0px oklch(0 0 0 / 0.07), 0px 3px 8px 0px oklch( + 0 0 0 / 0.07 + ); + --rs-shadow-floating: + 0px 1px 1px 0px oklch(0 0 0 / 0.04), 0px 2px 8px 0px oklch(0 0 0 / 0.04), 0px 3px 17px 0px oklch( + 0 0 0 / 0.04 + ), 0px 4px 30px 0px oklch(0 0 0 / 0.13); --rs-shadow-inset: 0px 1px 1px 0px oklch(0 0 0 / 0.04) inset; } [data-theme="dark"] { - --rs-shadow-feather: 0px 1px 1px 0px oklch(0 0 0 / 0.3), 0px 4px 4px -1px oklch(0 0 0 / 0.25); - --rs-shadow-soft: 0px 2px 4px 0px oklch(0 0 0 / 0.3), 0px 1px 2px 0px oklch(0 0 0 / 0.25); - --rs-shadow-lifted: 0px 1px 1px 0px oklch(0 0 0 / 0.35), 0px 2px 5px 0px oklch(0 0 0 / 0.35), 0px 3px 8px 0px oklch(0 0 0 / 0.35); - --rs-shadow-floating: 0px 1px 1px 0px oklch(0 0 0 / 0.3), 0px 2px 8px 0px oklch(0 0 0 / 0.3), 0px 3px 17px 0px oklch(0 0 0 / 0.3), 0px 4px 30px 0px oklch(0 0 0 / 0.4); + --rs-shadow-feather: + 0px 1px 1px 0px oklch(0 0 0 / 0.3), 0px 4px 4px -1px oklch(0 0 0 / 0.25); + --rs-shadow-soft: + 0px 2px 4px 0px oklch(0 0 0 / 0.3), 0px 1px 2px 0px oklch(0 0 0 / 0.25); + --rs-shadow-lifted: + 0px 1px 1px 0px oklch(0 0 0 / 0.35), 0px 2px 5px 0px oklch(0 0 0 / 0.35), 0px 3px 8px 0px oklch( + 0 0 0 / 0.35 + ); + --rs-shadow-floating: + 0px 1px 1px 0px oklch(0 0 0 / 0.3), 0px 2px 8px 0px oklch(0 0 0 / 0.3), 0px 3px 17px 0px oklch( + 0 0 0 / 0.3 + ), 0px 4px 30px 0px oklch(0 0 0 / 0.4); --rs-shadow-inset: 0px 1px 1px 0px oklch(0 0 0 / 0.25) inset; } From 28eedbd88506fada740520d6e9db707a7c2a9756 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:33 +0530 Subject: [PATCH 02/61] fix: retune the type ramp tracking and leading Step body letter-spacing and h1-h4 line-heights so they change monotonically across sizes and follow Inter's own tracking curve. --- packages/raystack/styles/typography.css | 42 ++++++++++--------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/raystack/styles/typography.css b/packages/raystack/styles/typography.css index bc329377d..3598f1f7c 100644 --- a/packages/raystack/styles/typography.css +++ b/packages/raystack/styles/typography.css @@ -29,17 +29,19 @@ /* Body Line Heights */ --rs-line-height-micro: 12px; - --rs-line-height-mini: 16px; + --rs-line-height-mini: 14px; --rs-line-height-small: 16px; --rs-line-height-regular: 20px; --rs-line-height-large: 24px; - /* Body Letter spacing */ - --rs-letter-spacing-micro: 0.5px; - --rs-letter-spacing-mini: 0.5px; - --rs-letter-spacing-small: 0.4px; - --rs-letter-spacing-regular: 0.25px; - --rs-letter-spacing-large: 0.5px; + /* Body Letter spacing — tuned for Inter, which is spaced generously already. + Near-zero at small sizes, slightly negative as size grows. em-based so it + scales with font-size. */ + --rs-letter-spacing-micro: 0.01em; + --rs-letter-spacing-mini: 0.005em; + --rs-letter-spacing-small: 0em; + --rs-letter-spacing-regular: -0.006em; + --rs-letter-spacing-large: -0.011em; /* Title Font Sizes */ --rs-font-size-t1: 20px; @@ -48,16 +50,16 @@ --rs-font-size-t4: 32px; /* Title Line Heights */ - --rs-line-height-t1: 24px; + --rs-line-height-t1: 28px; --rs-line-height-t2: 32px; --rs-line-height-t3: 36px; --rs-line-height-t4: 40px; /* Title Letter Spacing */ - --rs-letter-spacing-t1: 0; - --rs-letter-spacing-t2: 0; - --rs-letter-spacing-t3: 0; - --rs-letter-spacing-t4: 0; + --rs-letter-spacing-t1: -0.01em; + --rs-letter-spacing-t2: -0.013em; + --rs-letter-spacing-t3: -0.016em; + --rs-letter-spacing-t4: -0.02em; /* Mono Font Sizes */ --rs-font-size-mono-mini: 11px; @@ -103,19 +105,19 @@ h1 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t4); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t4); } h2 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t3); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t3); } h3 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t2); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t2); } h4 { font-family: var(--rs-font-title); @@ -130,13 +132,3 @@ textarea { caret-color: var(--rs-color-border-accent-emphasis); } -/* Keyframe Animation */ -@keyframes dissolve-hide { - 0%, - 50% { - opacity: 1; - } - 100% { - opacity: 0; - } -} From e8c11653f6011acb5c503cbe5f56d12d3ab5f720 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:33 +0530 Subject: [PATCH 03/61] fix: table and data-table stylesheet states Give numeric columns tabular figures so digits align, alongside the shared row-selection and sticky-header styling that lives in the same two stylesheets. --- .../data-table/data-table.module.css | 40 +++++++++++++++++++ .../components/table/table.module.css | 31 ++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css index 42583f19c..21243cb63 100644 --- a/packages/raystack/components/data-table/data-table.module.css +++ b/packages/raystack/components/data-table/data-table.module.css @@ -102,6 +102,8 @@ .contentRoot table { border-collapse: separate; border-spacing: 0; + /* Align digits across rows/columns for numeric data. */ + font-variant-numeric: tabular-nums; } .contentRoot thead { @@ -120,6 +122,16 @@ cursor: pointer; } +.row.clickable:active { + background: var(--rs-color-background-neutral-secondary); +} + +.row[data-state="selected"], +.row[data-state="selected"]:hover, +.row[data-state="selected"].clickable:active { + background: var(--rs-color-background-accent-primary); +} + .head { position: sticky; top: 0; @@ -192,6 +204,13 @@ box-sizing: border-box; } +/* Let virtualized cells (tableStyles.cell paints an opaque background) show + the row's hover/selected/pressed color, like `.cell { background: inherit }` + already does for s. `.virtualRow .virtualCell` outspecifies `.cell`. */ +.virtualRow .virtualCell { + background: inherit; +} + .virtualSectionHeader { position: absolute; width: 100%; @@ -254,3 +273,24 @@ flex: 1; min-width: 0; } + +/* Scrolled-under edge for the virtualized sticky group anchor. The anchor is + permanently stuck and coincides with the first natural group header at + scroll 0, so "scrolled > 0" is exactly "content is underneath it". + (.stickySectionHeader is intentionally NOT gated: its many instances stick + at per-element offsets a scroll() timeline can't express — revisit with + @container scroll-state(stuck: top) when support matures.) */ +@supports (animation-timeline: scroll()) { + .stickyGroupAnchor { + box-shadow: 0 0.5px 0 0 transparent; + animation: anchor-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } +} + +@keyframes anchor-edge { + to { + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); + } +} diff --git a/packages/raystack/components/table/table.module.css b/packages/raystack/components/table/table.module.css index 93a463865..a7eb49e84 100644 --- a/packages/raystack/components/table/table.module.css +++ b/packages/raystack/components/table/table.module.css @@ -6,6 +6,8 @@ overflow: visible; color: var(--rs-color-foreground-base-primary); border-collapse: collapse; + /* Align digits across rows/columns for numeric data. */ + font-variant-numeric: tabular-nums; } .header { @@ -66,3 +68,32 @@ line-height: var(--rs-line-height-small); letter-spacing: var(--rs-letter-spacing-small); } + +/* Row state hooks. `.row` is applied by Table.Row; visuals only kick in when + the consumer sets data-state="selected" or the `interactive` prop. Cells + inherit so the row color shows through the opaque .cell background. */ +.row { + background: var(--rs-color-background-base-primary); +} + +.row > .cell { + background: inherit; +} + +.row-interactive { + cursor: pointer; +} + +.row-interactive:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.row-interactive:active { + background: var(--rs-color-background-neutral-secondary); +} + +.row[data-state="selected"], +.row[data-state="selected"]:hover, +.row[data-state="selected"]:active { + background: var(--rs-color-background-accent-primary); +} From fa1249290180da1d3746d64758858247888da4fd Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:34 +0530 Subject: [PATCH 04/61] fix(spinner): speed the loop up from 1.2s to 0.9s 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. --- .../components/spinner/spinner.module.css | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/raystack/components/spinner/spinner.module.css b/packages/raystack/components/spinner/spinner.module.css index 029907e55..13c2a3172 100644 --- a/packages/raystack/components/spinner/spinner.module.css +++ b/packages/raystack/components/spinner/spinner.module.css @@ -16,41 +16,49 @@ .pole:nth-child(1) { transform: rotate(0deg); animation-delay: 0s; + opacity: 0.8; } .pole:nth-child(2) { transform: rotate(-45deg); - animation-delay: -0.15s; + animation-delay: -0.1125s; + opacity: 0.7; } .pole:nth-child(3) { transform: rotate(-90deg); - animation-delay: -0.3s; + animation-delay: -0.225s; + opacity: 0.6; } .pole:nth-child(4) { transform: rotate(-135deg); - animation-delay: -0.45s; + animation-delay: -0.3375s; + opacity: 0.5; } .pole:nth-child(5) { transform: rotate(-180deg); - animation-delay: -0.6s; + animation-delay: -0.45s; + opacity: 0.4; } .pole:nth-child(6) { transform: rotate(-225deg); - animation-delay: -0.75s; + animation-delay: -0.5625s; + opacity: 0.3; } .pole:nth-child(7) { transform: rotate(-270deg); - animation-delay: -0.9s; + animation-delay: -0.675s; + opacity: 0.2; } .pole:nth-child(8) { transform: rotate(-315deg); - animation-delay: -1.05s; + animation-delay: -0.7875s; + opacity: 0.1; } @keyframes spin { @@ -90,7 +98,7 @@ @media (prefers-reduced-motion: no-preference) { .pole { - animation: spin 1.2s linear infinite; + animation: spin 0.9s linear infinite; } } From 9fb79c3f44933af248714d027dc3b451d9134968 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:34 +0530 Subject: [PATCH 05/61] fix(floating-actions): margin-based centering, fade+rise entrance 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). --- .../floating-actions.module.css | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/floating-actions/floating-actions.module.css b/packages/raystack/components/floating-actions/floating-actions.module.css index 3d39ad781..f6d26ebb4 100644 --- a/packages/raystack/components/floating-actions/floating-actions.module.css +++ b/packages/raystack/components/floating-actions/floating-actions.module.css @@ -8,6 +8,31 @@ background: var(--rs-color-background-base-primary); border-radius: var(--rs-radius-2); box-shadow: var(--rs-shadow-lifted); + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); +} + +@starting-style { + .root { + opacity: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .root { + transition: + opacity var(--rs-duration-normal) var(--rs-ease-out), + transform var(--rs-duration-normal) var(--rs-ease-out); + } + + @starting-style { + .root[data-variant="floating"][data-side="bottom"] { + transform: translateY(var(--rs-space-3)); + } + + .root[data-variant="floating"][data-side="top"] { + transform: translateY(calc(-1 * var(--rs-space-3))); + } + } } /* ===== Floating variant ===== */ @@ -31,8 +56,9 @@ } .root[data-variant="floating"][data-align="center"] { - left: 50%; - transform: translateX(-50%); + left: 0; + right: 0; + margin-inline: auto; } .root[data-variant="floating"][data-align="end"] { From d49074e04e3aef9aac7f7db1eeae13b0eb858fec Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:34 +0530 Subject: [PATCH 06/61] fix: guard Image's fallback re-entry, fade in loaded images/avatars 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. --- .../components/avatar/avatar.module.css | 34 ++++++++-- .../raystack/components/avatar/avatar.tsx | 65 ++++++++++++++----- .../components/image/__tests__/image.test.tsx | 12 ++++ .../components/image/image.module.css | 16 +++++ packages/raystack/components/image/image.tsx | 46 +++++++++++-- 5 files changed, 149 insertions(+), 24 deletions(-) diff --git a/packages/raystack/components/avatar/avatar.module.css b/packages/raystack/components/avatar/avatar.module.css index d435c0351..3c08594f0 100644 --- a/packages/raystack/components/avatar/avatar.module.css +++ b/packages/raystack/components/avatar/avatar.module.css @@ -11,10 +11,11 @@ width: var(--rs-space-9, 32px); height: var(--rs-space-9, 32px); --fallback-font-size: calc(var(--rs-space-9, 32px) * 0.4); + --fallback-letter-spacing: 0.03em; } -.avatar-full { - border-radius: var(--rs-radius-full) !important; +.avatar.avatar-full { + border-radius: var(--rs-radius-full); } .avatar-disabled { @@ -228,6 +229,18 @@ vertical-align: middle; } +@keyframes avatar-image-in { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .image-fade-in { + animation: avatar-image-in var(--rs-duration-fast) var(--rs-ease-out); + } +} + .fallback { width: 100%; height: 100%; @@ -236,14 +249,15 @@ justify-content: center; font-size: var(--fallback-font-size); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-large); - letter-spacing: var(--rs-letter-spacing-large); + line-height: 1; + letter-spacing: var(--fallback-letter-spacing, 0.03em); } .avatar-size-1 { width: var(--rs-space-5, 16px); height: var(--rs-space-5, 16px); --fallback-font-size: calc(var(--rs-space-5, 16px) * 0.4); + --fallback-letter-spacing: 0.05em; border-radius: var(--rs-radius-2); } @@ -251,6 +265,7 @@ width: var(--rs-space-6, 20px); height: var(--rs-space-6, 20px); --fallback-font-size: calc(var(--rs-space-6, 20px) * 0.4); + --fallback-letter-spacing: 0.05em; border-radius: var(--rs-radius-2); } @@ -258,6 +273,7 @@ width: var(--rs-space-7, 24px); height: var(--rs-space-7, 24px); --fallback-font-size: calc(var(--rs-space-7, 24px) * 0.4); + --fallback-letter-spacing: 0.04em; border-radius: var(--rs-radius-2); } @@ -265,6 +281,7 @@ width: var(--rs-space-8, 28px); height: var(--rs-space-8, 28px); --fallback-font-size: calc(var(--rs-space-8, 28px) * 0.35); + --fallback-letter-spacing: 0.04em; border-radius: var(--rs-radius-2); } @@ -272,6 +289,7 @@ width: var(--rs-space-9, 32px); height: var(--rs-space-9, 32px); --fallback-font-size: calc(var(--rs-space-9, 32px) * 0.4); + --fallback-letter-spacing: 0.03em; border-radius: var(--rs-radius-2); } @@ -279,6 +297,7 @@ width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); --fallback-font-size: calc(var(--rs-space-10, 40px) * 0.35); + --fallback-letter-spacing: 0.02em; border-radius: var(--rs-radius-4); } @@ -286,6 +305,7 @@ width: var(--rs-space-11, 48px); height: var(--rs-space-11, 48px); --fallback-font-size: calc(var(--rs-space-11, 48px) * 0.35); + --fallback-letter-spacing: 0.01em; border-radius: var(--rs-radius-4); } @@ -293,6 +313,7 @@ width: var(--rs-space-12, 56px); height: var(--rs-space-12, 56px); --fallback-font-size: calc(var(--rs-space-12, 56px) * 0.3); + --fallback-letter-spacing: 0.01em; border-radius: var(--rs-radius-4); } @@ -300,6 +321,7 @@ width: var(--rs-space-13, 64px); height: var(--rs-space-13, 64px); --fallback-font-size: calc(var(--rs-space-13, 64px) * 0.3); + --fallback-letter-spacing: 0em; border-radius: var(--rs-radius-4); } @@ -307,6 +329,7 @@ width: var(--rs-space-14, 72px); height: var(--rs-space-14, 72px); --fallback-font-size: calc(var(--rs-space-14, 72px) * 0.3); + --fallback-letter-spacing: 0em; border-radius: var(--rs-radius-5); } @@ -314,6 +337,7 @@ width: var(--rs-space-15, 80px); height: var(--rs-space-15, 80px); --fallback-font-size: calc(var(--rs-space-15, 80px) * 0.3); + --fallback-letter-spacing: 0em; border-radius: var(--rs-radius-5); } @@ -321,6 +345,7 @@ width: var(--rs-space-16, 96px); height: var(--rs-space-16, 96px); --fallback-font-size: calc(var(--rs-space-16, 96px) * 0.3); + --fallback-letter-spacing: -0.005em; border-radius: var(--rs-radius-5); } @@ -328,6 +353,7 @@ width: var(--rs-space-17, 120px); height: var(--rs-space-17, 120px); --fallback-font-size: calc(var(--rs-space-17, 120px) * 0.3); + --fallback-letter-spacing: -0.01em; border-radius: var(--rs-radius-5); } diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index 48dde77eb..e8fca4671 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -1,9 +1,21 @@ +'use client'; + import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'; import { cva, cx, VariantProps } from 'class-variance-authority'; -import { ComponentProps, isValidElement, ReactElement, ReactNode } from 'react'; +import { + ComponentProps, + isValidElement, + ReactElement, + ReactNode, + useRef, + useState +} from 'react'; import styles from './avatar.module.css'; import { AVATAR_COLORS } from './utils'; +// Matches Base UI's AvatarRoot ImageLoadingStatus union. +type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; + const avatar = cva(styles.avatar, { variants: { radius: { @@ -164,21 +176,42 @@ const AvatarRoot = ({ variant, color, ...props -}: AvatarProps) => ( - - - - {fallback} - - -); +}: AvatarProps) => { + const sawLoadingRef = useRef(false); + const [fadeIn, setFadeIn] = useState(false); + const handleLoadingStatusChange = (status: ImageLoadingStatus) => { + if (status === 'loading') { + sawLoadingRef.current = true; + setFadeIn(false); + } else if (status === 'loaded') { + // A cached image reports 'loaded' with no 'loading' phase (Base UI never + // emits it), so it stays instant; only network loads fade in. + setFadeIn(sawLoadingRef.current); + sawLoadingRef.current = false; + } + }; + + return ( + + + + {fallback} + + + ); +}; AvatarRoot.displayName = 'Avatar'; diff --git a/packages/raystack/components/image/__tests__/image.test.tsx b/packages/raystack/components/image/__tests__/image.test.tsx index 5c95b994c..92d17a448 100644 --- a/packages/raystack/components/image/__tests__/image.test.tsx +++ b/packages/raystack/components/image/__tests__/image.test.tsx @@ -124,6 +124,18 @@ describe('Image', () => { fireEvent.error(img); expect(img.src).toContain('/fallback.jpg'); }); + + it('assigns the fallback src exactly once, even if the fallback also errors', () => { + render(Test); + const img = screen.getByRole('img') as HTMLImageElement; + + fireEvent.error(img); + expect(img.src).toContain('/fallback.jpg'); + + // The fallback itself fails to load — must not re-assign it again. + fireEvent.error(img); + expect(img.src).toContain('/fallback.jpg'); + }); }); describe('Loading Attributes', () => { diff --git a/packages/raystack/components/image/image.module.css b/packages/raystack/components/image/image.module.css index fea2c7db5..97e4ec318 100644 --- a/packages/raystack/components/image/image.module.css +++ b/packages/raystack/components/image/image.module.css @@ -31,3 +31,19 @@ .image-radius-full { border-radius: var(--rs-radius-full); } + +/* Load fade: hidden only after JS confirms an in-flight load, so SSR/no-JS + images are never invisible. Cached images skip this entirely. */ +.image-loading { + opacity: 0; +} + +.image-loaded { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .image-loaded { + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} diff --git a/packages/raystack/components/image/image.tsx b/packages/raystack/components/image/image.tsx index 9bae64407..e250b2a18 100644 --- a/packages/raystack/components/image/image.tsx +++ b/packages/raystack/components/image/image.tsx @@ -1,7 +1,8 @@ 'use client'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { ComponentProps, SyntheticEvent } from 'react'; +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { ComponentProps, SyntheticEvent, useRef, useState } from 'react'; +import { useIsomorphicLayoutEffect } from '~/hooks'; import styles from './image.module.css'; @@ -36,6 +37,8 @@ export function Image({ radius, fallback, onError, + onLoad, + src, width, height, style, @@ -43,9 +46,33 @@ export function Image({ decoding = 'async', ...props }: ImageProps) { + const imgRef = useRef(null); + const hasFallenBackRef = useRef(false); + const [loadState, setLoadState] = useState<'static' | 'loading' | 'loaded'>( + 'static' + ); + + useIsomorphicLayoutEffect(() => { + hasFallenBackRef.current = false; + const node = imgRef.current; + // Already-decoded (cached/SSR-painted) images stay visible — no fade. + setLoadState(node && !node.complete ? 'loading' : 'static'); + }, [src]); + + const handleLoad = (event: SyntheticEvent) => { + setLoadState(prev => (prev === 'loading' ? 'loaded' : prev)); + onLoad?.(event); + }; + const handleError = (event: SyntheticEvent) => { - if (fallback) { + if (fallback && !hasFallenBackRef.current) { + // One-shot: if the fallback itself errors, never re-assign it. + hasFallenBackRef.current = true; event.currentTarget.src = fallback; + } else { + // No fallback (or the fallback failed): show the alt/broken rendering + // instead of holding the img invisible. + setLoadState('static'); } onError?.(event); }; @@ -58,9 +85,20 @@ export function Image({ return ( {alt} Date: Mon, 13 Jul 2026 02:58:34 +0530 Subject: [PATCH 07/61] fix: scope Headline's width:100% to the truncate variant 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. --- packages/raystack/components/headline/headline.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/raystack/components/headline/headline.module.css b/packages/raystack/components/headline/headline.module.css index 0d7b9f809..a71f2ccf0 100644 --- a/packages/raystack/components/headline/headline.module.css +++ b/packages/raystack/components/headline/headline.module.css @@ -5,7 +5,6 @@ font-family: var(--rs-font-title); font-weight: var(--rs-font-weight-regular); color: var(--rs-color-foreground-base-primary); - width: 100%; } .headline-t1 { From f5aa13487cd9a42c19d79c17217f14cdb8a13935 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:35 +0530 Subject: [PATCH 08/61] feat: give the announcement bar action button interactive states The action button was the bar's only interactive element but had no hover, press, or focus feedback. Add opacity-step hover/press states and a currentColor focus ring that stays visible against the bar's coloured backgrounds. --- .../announcement-bar/announcement-bar.module.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/raystack/components/announcement-bar/announcement-bar.module.css b/packages/raystack/components/announcement-bar/announcement-bar.module.css index f0a249bf9..a02389463 100644 --- a/packages/raystack/components/announcement-bar/announcement-bar.module.css +++ b/packages/raystack/components/announcement-bar/announcement-bar.module.css @@ -50,4 +50,19 @@ padding: 0; font: inherit; color: inherit; + border-radius: var(--rs-radius-1); + transition: var(--rs-transition-interactive); +} + +.action-btn:hover { + opacity: 0.8; +} + +.action-btn:active { + opacity: 0.64; +} + +.action-btn:focus-visible { + outline: 1px solid currentColor; + outline-offset: 2px; } From 4fa7ae4971bbcce1f83779e7e8750042e8fc2ff2 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:35 +0530 Subject: [PATCH 09/61] fix: tabs standalone/plain variants glide their indicator instead of 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. --- .../raystack/components/tabs/tabs.module.css | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index b8d447a1f..0e0aee0d5 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -50,7 +50,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; border-radius: var(--rs-radius-2); - transition: color 0.2s ease; + transition: color var(--rs-duration-normal) var(--rs-ease-out); flex: 1; text-align: center; text-overflow: ellipsis; @@ -79,6 +79,24 @@ pointer-events: none; } +.trigger:focus-visible { + outline: var(--rs-focus-ring); +} + +/* Pressed: the label dips inward. No background box on any variant, so the + press never competes with the indicator chip that glides in on release. */ +.trigger:active:not([data-disabled]):not([data-active]) { + transform: scale(var(--rs-scale-pressed)); +} + +@media (prefers-reduced-motion: no-preference) { + .trigger { + transition: + color var(--rs-duration-normal) var(--rs-ease-out), + transform var(--rs-duration-press) var(--rs-ease-out); + } +} + .trigger-icon { display: inline-flex; width: var(--rs-space-5); @@ -94,8 +112,9 @@ background-color: var(--rs-color-background-base-primary); border-radius: var(--rs-radius-2); box-shadow: var(--rs-shadow-feather); - top: var(--active-tab-top, 0); - left: var(--active-tab-left, 0); + top: 0; + left: 0; + translate: var(--active-tab-left, 0) var(--active-tab-top, 0); width: var(--active-tab-width, 0); height: var(--active-tab-height, 0); z-index: 0; @@ -104,10 +123,9 @@ @media (prefers-reduced-motion: no-preference) { .indicator { transition: - top 0.25s cubic-bezier(0.4, 0, 0.2, 1), - left 0.25s cubic-bezier(0.4, 0, 0.2, 1), - width 0.25s cubic-bezier(0.4, 0, 0.2, 1), - height 0.25s cubic-bezier(0.4, 0, 0.2, 1); + translate var(--rs-duration-moderate) var(--rs-ease-in-out), + width var(--rs-duration-moderate) var(--rs-ease-in-out), + height var(--rs-duration-moderate) var(--rs-ease-in-out); } } @@ -127,9 +145,22 @@ box-shadow: none; } -.variant-standalone .indicator, +/* Standalone: the indicator is the active chip fill, gliding between tabs. */ +.variant-standalone .indicator { + background-color: var(--rs-color-background-neutral-primary); + border: 0.5px solid var(--rs-color-border-base-secondary); + box-sizing: border-box; + box-shadow: none; +} + +/* Plain: the indicator is a 1px underline sliding along the bottom edge. */ .variant-plain .indicator { - display: none; + height: 1px; + translate: var(--active-tab-left, 0px) + calc(var(--active-tab-top, 0px) + var(--active-tab-height, 0px) - 1px); + background-color: var(--rs-color-border-base-emphasis); + border-radius: 0; + box-shadow: none; } /* Standalone variant: chip-like tabs with independent borders */ @@ -140,8 +171,8 @@ } .variant-standalone .trigger[data-active] { - background: var(--rs-color-background-neutral-primary); - border: 0.5px solid var(--rs-color-border-base-secondary); + background: transparent; + border-color: transparent; } /* Plain variant: text-like tabs with larger gaps */ @@ -160,7 +191,3 @@ /* Reserve underline space to prevent layout/typography shifting when active */ border-bottom: 1px solid transparent; } - -.variant-plain .trigger[data-active] { - border-bottom-color: var(--rs-color-border-base-emphasis); -} \ No newline at end of file From 7b65eeac6201dc8dd4fd8b3d3339d2f0c9027603 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:35 +0530 Subject: [PATCH 10/61] fix: ease the breadcrumb link hover Breadcrumb links changed colour instantly on hover while the rest of the library eases. Add a short colour transition, gated behind prefers-reduced-motion, so the hover matches every other interactive element. --- .../breadcrumb/breadcrumb.module.css | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index e921ca908..3f5e508d6 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -39,10 +39,23 @@ cursor: pointer; } +@media (prefers-reduced-motion: no-preference) { + .breadcrumb-link { + transition: color var(--rs-duration-fast) var(--rs-ease-out); + } +} + .breadcrumb-link:hover { color: var(--rs-color-foreground-base-secondary); } +.breadcrumb-link:focus-visible { + outline: none; + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-secondary); + border-radius: var(--rs-radius-1); +} + .breadcrumb-link-active { color: var(--rs-color-foreground-base-primary); font-weight: var(--rs-font-weight-medium); @@ -81,6 +94,12 @@ padding: 0; } +.breadcrumb-dropdown-trigger:focus-visible { + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-primary); + border-radius: var(--rs-radius-1); +} + .breadcrumb-dropdown-icon { height: var(--rs-space-3); } From 050838813a76ad15f8e6df0e64fc6b227170bbfd Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:35 +0530 Subject: [PATCH 11/61] fix: align popover shadow depth with its surface size 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. --- .../components/popover/popover.module.css | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/raystack/components/popover/popover.module.css b/packages/raystack/components/popover/popover.module.css index 85880bd4a..89d75e9f8 100644 --- a/packages/raystack/components/popover/popover.module.css +++ b/packages/raystack/components/popover/popover.module.css @@ -16,21 +16,24 @@ box-shadow: var(--rs-shadow-soft); border: 1px solid var(--rs-color-border-base-primary); color: var(--rs-color-foreground-base-primary); + transform-origin: var(--transform-origin); + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); } -@keyframes slideUpAndFade { - from { - opacity: 0; - transform: translateY(var(--rs-space-1)); - } - to { - opacity: 1; - transform: translateY(0); - } +.popover[data-starting-style], +.popover[data-ending-style] { + opacity: 0; } @media (prefers-reduced-motion: no-preference) { .popover { - animation: slideUpAndFade 400ms cubic-bezier(0.16, 1, 0.3, 1); + transition: + opacity var(--rs-duration-normal) var(--rs-ease-out), + transform var(--rs-duration-normal) var(--rs-ease-out); + } + + .popover[data-starting-style], + .popover[data-ending-style] { + transform: scale(0.97); } } From d585271f5502aec12ec0368159761a0c2c203e92 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:35 +0530 Subject: [PATCH 12/61] fix: side-panel overflow:auto, document as persistent layout 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. --- packages/raystack/components/side-panel/side-panel.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/raystack/components/side-panel/side-panel.module.css b/packages/raystack/components/side-panel/side-panel.module.css index f4526ee63..1fd61cc17 100644 --- a/packages/raystack/components/side-panel/side-panel.module.css +++ b/packages/raystack/components/side-panel/side-panel.module.css @@ -1,7 +1,7 @@ .side-panel { width: 400px; background: var(--rs-color-background-base-primary); - overflow: scroll; + overflow: auto; } .side-panel-left { From 57d106f0ce6779dc73f7b5b7d44930fee68043b9 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:36 +0530 Subject: [PATCH 13/61] fix: give the scroll-area thumb a hover grow and grabbed state The scrollbar grew its whole track on hover, reflowing the page each time, and the thumb never showed it was grabbed. Keep the track a constant, invisible width so layout never shifts; draw the thumb thinner at rest and grow it to full width on hover; and deepen its colour one step on hover and again while dragging. --- .../scroll-area/scroll-area.module.css | 85 ++++++++++++++----- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/packages/raystack/components/scroll-area/scroll-area.module.css b/packages/raystack/components/scroll-area/scroll-area.module.css index 10395305f..a2400ee9f 100644 --- a/packages/raystack/components/scroll-area/scroll-area.module.css +++ b/packages/raystack/components/scroll-area/scroll-area.module.css @@ -1,6 +1,8 @@ :root { - --rs-scrollbar-size-default: var(--rs-space-2); - --rs-scrollbar-size-hover: 6px; + --rs-scrollbar-size: 6px; /* track + hit area, and the expanded thumb */ + --rs-scrollbar-thumb-rest: var( + --rs-space-2 + ); /* 4px — visible thumb at rest */ } .root { @@ -25,42 +27,83 @@ background: transparent; pointer-events: auto; position: relative; - transition: opacity 150ms ease-out; + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); } +/* Constant track = constant hit area; the track is transparent so hover never + changes layout (no content reflow). The visible bar is the thumb's ::before, + which is thinner at rest and grows to fill the track on hover. */ .scrollbar[data-orientation="vertical"] { - width: var(--rs-scrollbar-size-default); -} - -.scrollbar[data-orientation="vertical"]:hover { - width: var(--rs-scrollbar-size-hover); + width: var(--rs-scrollbar-size); } .scrollbar[data-orientation="horizontal"] { flex-direction: column; - height: var(--rs-scrollbar-size-default); -} - -.scrollbar[data-orientation="horizontal"]:hover { - height: var(--rs-scrollbar-size-hover); + height: var(--rs-scrollbar-size); } .thumb { flex: 1; - background: var(--rs-color-overlay-base-a5); - border-radius: var(--rs-radius-2); - transition: opacity 150ms ease-out; - opacity: 1; + position: relative; pointer-events: auto; touch-action: none; + opacity: 1; +} + +/* The drawn bar. The thumb stays the full-size hit area; this pseudo is what + you see. It's a pseudo because Base UI writes an inline transform on the + thumb to position it — so the thumb's own transform can't be used to size it, + and animating the thumb's transform would make it lag while scrolling. */ +.thumb::before { + content: ""; + position: absolute; + background: var(--rs-color-overlay-base-a5); + border-radius: var(--rs-radius-2); + transition: background-color var(--rs-duration-fast) var(--rs-ease-out); +} + +/* Vertical: bar hugs the outer (right) edge and grows inward. */ +.scrollbar[data-orientation="vertical"] .thumb::before { + top: 0; + bottom: 0; + right: 0; + width: var(--rs-scrollbar-thumb-rest); +} + +.scrollbar[data-orientation="vertical"]:hover .thumb::before, +.scrollbar[data-orientation="vertical"]:active .thumb::before { + width: var(--rs-scrollbar-size); +} + +/* Horizontal: bar hugs the bottom edge and grows upward. */ +.scrollbar[data-orientation="horizontal"] .thumb::before { + left: 0; + right: 0; + bottom: 0; + height: var(--rs-scrollbar-thumb-rest); +} + +.scrollbar[data-orientation="horizontal"]:hover .thumb::before, +.scrollbar[data-orientation="horizontal"]:active .thumb::before { + height: var(--rs-scrollbar-size); +} + +/* Deepen the bar one overlay step per state. Base UI exposes no data-pressed; + pointer capture holds :active for the whole drag. */ +.scrollbar:hover .thumb::before { + background: var(--rs-color-overlay-base-a6); +} + +.scrollbar:active .thumb::before { + background: var(--rs-color-overlay-base-a7); } @media (prefers-reduced-motion: no-preference) { - .scrollbar { + .thumb::before { transition: - width 150ms ease-out, - height 150ms ease-out, - opacity 150ms ease-out; + background-color var(--rs-duration-fast) var(--rs-ease-out), + width var(--rs-duration-fast) var(--rs-ease-out), + height var(--rs-duration-fast) var(--rs-ease-out); } } From 2832db33200a0eb59ee8a23177603084bc5fadbc Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:36 +0530 Subject: [PATCH 14/61] fix: scope preview-card inset/size transitions to viewport-morph mode 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. --- .../preview-card/preview-card.module.css | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/raystack/components/preview-card/preview-card.module.css b/packages/raystack/components/preview-card/preview-card.module.css index 9dd1760d7..575870f8a 100644 --- a/packages/raystack/components/preview-card/preview-card.module.css +++ b/packages/raystack/components/preview-card/preview-card.module.css @@ -1,6 +1,6 @@ .positioner { - --easing: cubic-bezier(0.22, 1, 0.36, 1); - --animation-duration: 0.35s; + --easing: var(--rs-ease-out); + --animation-duration: var(--rs-duration-moderate); z-index: var(--rs-z-index-portal); height: var(--positioner-height); @@ -103,17 +103,34 @@ } @media (prefers-reduced-motion: no-preference) { + /* Default mode (no PreviewCard.Viewport): Base UI positions the positioner + via transform (floating-ui). Don't transition that transform, or the card + eases toward every reposition and swims behind its anchor. The popup owns + the entrance (scale below); the positioner only fades — like popover/menu/tooltip. */ .positioner { + transition: opacity var(--animation-duration) var(--easing); + } + + .popup { + transition: + opacity var(--animation-duration) var(--easing), + transform var(--animation-duration) var(--easing); + } + + /* Viewport-morph mode: Base UI switches to inset positioning + (adaptiveOrigin) and drives --popup-width/--popup-height, so animating + inset + width/height IS the morph. Layout cost is confined to this popup + subtree for the duration of the morph. */ + .positioner:has(.viewport) { transition: top var(--animation-duration) var(--easing), left var(--animation-duration) var(--easing), right var(--animation-duration) var(--easing), bottom var(--animation-duration) var(--easing), - transform var(--animation-duration) var(--easing), opacity var(--animation-duration) var(--easing); } - .popup { + .popup:has(.viewport) { transition: width var(--animation-duration) var(--easing), height var(--animation-duration) var(--easing), From acdd909aead6bfdd429467e24daec6688e2d1b96 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:36 +0530 Subject: [PATCH 15/61] fix: reveal sticky-header hairlines only once content scrolls under them A sticky header drew its bottom hairline even at rest, before anything had scrolled beneath it. Drive the hairline from a scroll-linked animation so it fades in over the first bit of scroll, gated behind a real scroll container so the plain table keeps its static separator. --- .../components/data-view/data-view.module.css | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css index b61bbe31f..11820975c 100644 --- a/packages/raystack/components/data-view/data-view.module.css +++ b/packages/raystack/components/data-view/data-view.module.css @@ -52,6 +52,20 @@ .display-popover-sort-icon { height: var(--rs-space-6); width: var(--rs-space-6); + /* Flip around the icon's own visual centre, not the SVG viewBox origin, + so scaleY reads as a clean flip in place instead of a shift. */ + transform-box: fill-box; + transform-origin: center; +} + +.display-popover-sort-icon[data-order="desc"] { + transform: scaleY(-1); +} + +@media (prefers-reduced-motion: no-preference) { + .display-popover-sort-icon { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } } .flex-1 { @@ -205,6 +219,11 @@ background: var(--rs-color-background-base-primary-hover); } +.listRow.clickable:active, +.listRowVirtual.clickable:active { + background: var(--rs-color-background-neutral-secondary); +} + .listRowDivider { border-bottom: 0.5px solid var(--rs-color-border-base-primary); } @@ -326,3 +345,33 @@ width: 100%; box-sizing: border-box; } + +/* Scrolled-under edges: header hairline and group-anchor hairline appear only + once content scrolls beneath them. See table.module.css for rationale. */ +@supports (animation-timeline: scroll()) { + .listHeader { + border-bottom-color: transparent; + animation: list-header-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } + + .listGroupAnchor { + box-shadow: 0 0.5px 0 0 transparent; + animation: list-anchor-edge linear both; + animation-timeline: scroll(nearest block); + animation-range: 0px 24px; + } +} + +@keyframes list-header-edge { + to { + border-bottom-color: var(--rs-color-border-base-primary); + } +} + +@keyframes list-anchor-edge { + to { + box-shadow: 0 0.5px 0 0 var(--rs-color-border-base-primary); + } +} From a011f19a4a1023ddfeb34ab2c780abb5b52d7e50 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Mon, 13 Jul 2026 02:58:36 +0530 Subject: [PATCH 16/61] fix: unify calendar day-cell radius, cross-fade the loading skeleton 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. --- .../components/calendar/calendar.module.css | 74 ++++++++++++++++++- .../raystack/components/calendar/calendar.tsx | 29 +++++--- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css index 6fa3fc589..e2f5bb475 100644 --- a/packages/raystack/components/calendar/calendar.module.css +++ b/packages/raystack/components/calendar/calendar.module.css @@ -73,7 +73,7 @@ width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); background-color: var(--rs-color-background-base-primary); - border-radius: var(--rs-radius-2); + border-radius: var(--rs-radius-5); color: var(--rs-color-foreground-base-primary); font-style: normal; text-align: center; @@ -92,11 +92,22 @@ width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); flex-shrink: 0; - border-radius: var(--rs-radius-5); border: 1px solid var(--rs-color-border-accent-emphasis-hover); background-color: transparent; } +.day:not(.disabled):not(.outside):not(.selected) .dayButton:active { + background-color: var(--rs-color-background-base-primary-hover); +} + +.selected:not(.rangeMiddle) .dayButton:active { + background-color: var(--rs-color-background-accent-emphasis-hover); +} + +.rangeMiddle .dayButton:active { + background-color: var(--rs-color-background-neutral-secondary); +} + .selected:not(.rangeMiddle) { width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); @@ -287,6 +298,65 @@ margin-top: var(--rs-space-2); } +.monthGridWrap { + position: relative; +} + +.monthGridSkeleton { + position: absolute; + inset: 0; + /* Solid backing so the grid underneath doesn't ghost through mid-fade */ + background: var(--rs-color-background-base-primary); + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.monthGridSkeleton[data-visible] { + opacity: 1; + visibility: visible; + /* Block clicks on the day grid underneath while loading */ + pointer-events: auto; +} + +@media (prefers-reduced-motion: no-preference) { + .monthGridSkeleton { + /* Exiting: fade opacity, then flip visibility after the fade */ + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + visibility 0s linear var(--rs-duration-fast); + } + + .monthGridSkeleton[data-visible] { + /* Entering: visibility flips immediately, opacity fades in */ + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} + .datePickerInput { text-align: left; } + +.datePickerError { + display: block; + /* Reserved line: the space exists whether or not an error is showing, + so appearing/disappearing errors never shift the layout below. */ + min-height: var(--rs-line-height-mini); + margin-top: var(--rs-space-1); + color: var(--rs-color-foreground-danger-primary); + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + opacity: 0; +} + +.datePickerError[data-visible] { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .datePickerError { + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} diff --git a/packages/raystack/components/calendar/calendar.tsx b/packages/raystack/components/calendar/calendar.tsx index 2cc0c8bc8..68612b046 100644 --- a/packages/raystack/components/calendar/calendar.tsx +++ b/packages/raystack/components/calendar/calendar.tsx @@ -176,6 +176,7 @@ export const Calendar = function ({ render={