| null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
+ };
+ }, []);
+
+ function scheduleReset() {
+ if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
+ resetTimerRef.current = setTimeout(() => {
+ setStatus('idle');
+ resetTimerRef.current = null;
+ }, resetTimeout);
+ }
async function onCopy() {
const res = await copy(text);
if (res) {
- setIsCopied(true);
- if (resetIcon) {
- setTimeout(() => {
- setIsCopied(false);
- }, resetTimeout);
- }
+ setStatus('copied');
+ if (resetIcon) scheduleReset();
+ } else {
+ setStatus('error');
+ scheduleReset();
}
}
return (
-
- {isCopied ? (
-
- ) : (
-
- )}
-
+ <>
+
+
+
+
+
+
+
+
+ {status === 'copied'
+ ? 'Copied'
+ : status === 'error'
+ ? 'Copy failed'
+ : ''}
+
+ >
);
}
diff --git a/packages/raystack/components/data-table/components/ordering.tsx b/packages/raystack/components/data-table/components/ordering.tsx
index 326a7db53..52b88249d 100644
--- a/packages/raystack/components/data-table/components/ordering.tsx
+++ b/packages/raystack/components/data-table/components/ordering.tsx
@@ -1,6 +1,6 @@
'use client';
-import { TextAlignBottomIcon, TextAlignTopIcon } from '@radix-ui/react-icons';
+import { TextAlignBottomIcon } from '@radix-ui/react-icons';
import { Flex } from '../../flex';
import { IconButton } from '../../icon-button';
@@ -70,13 +70,10 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) {
size={4}
disabled={columnList.length === 0}
>
- {value.order === SortOrders?.ASC ? (
-
- ) : (
-
- )}
+
diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css
index 42583f19c..c4c42525c 100644
--- a/packages/raystack/components/data-table/data-table.module.css
+++ b/packages/raystack/components/data-table/data-table.module.css
@@ -48,6 +48,21 @@
padding: var(--rs-space-3) var(--rs-space-5);
}
+.display-popover-sort-icon {
+ height: var(--rs-space-6);
+ width: var(--rs-space-6);
+}
+
+.display-popover-sort-icon[data-order="desc"] {
+ transform: rotate(180deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .display-popover-sort-icon {
+ transition: transform var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
.flex-1 {
flex: 1;
}
@@ -120,6 +135,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 +217,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 +286,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/data-view-beta/data-view.module.css b/packages/raystack/components/data-view-beta/data-view.module.css
index 12078f2f7..b215dabf4 100644
--- a/packages/raystack/components/data-view-beta/data-view.module.css
+++ b/packages/raystack/components/data-view-beta/data-view.module.css
@@ -108,6 +108,16 @@
cursor: pointer;
}
+.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;
@@ -180,6 +190,13 @@
box-sizing: border-box;
}
+/* Let virtualized cells show the row's hover/selected/pressed color, like
+ the non-virtualized cells already do via inherited background.
+ `.virtualRow .virtualCell` outspecifies the base `.cell`/`.virtualCell` rule. */
+.virtualRow .virtualCell {
+ background: inherit;
+}
+
.virtualSectionHeader {
position: absolute;
width: 100%;
diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view/components/ordering.tsx
index 1fcaed09e..850954f13 100644
--- a/packages/raystack/components/data-view/components/ordering.tsx
+++ b/packages/raystack/components/data-view/components/ordering.tsx
@@ -1,6 +1,6 @@
'use client';
-import { TextAlignBottomIcon, TextAlignTopIcon } from '@radix-ui/react-icons';
+import { TextAlignBottomIcon } from '@radix-ui/react-icons';
import { Flex } from '../../flex';
import { IconButton } from '../../icon-button';
@@ -68,13 +68,10 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) {
size={4}
disabled={columnList.length === 0}
>
- {value.order === SortOrders?.ASC ? (
-
- ) : (
-
- )}
+
diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css
index b61bbe31f..6fc804a1b 100644
--- a/packages/raystack/components/data-view/data-view.module.css
+++ b/packages/raystack/components/data-view/data-view.module.css
@@ -54,6 +54,16 @@
width: var(--rs-space-6);
}
+.display-popover-sort-icon[data-order="desc"] {
+ transform: rotate(180deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .display-popover-sort-icon {
+ transition: transform var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
.flex-1 {
flex: 1;
}
@@ -205,6 +215,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 +341,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);
+ }
+}
diff --git a/packages/raystack/components/dialog/dialog.module.css b/packages/raystack/components/dialog/dialog.module.css
index 085c2eba8..1935b4212 100644
--- a/packages/raystack/components/dialog/dialog.module.css
+++ b/packages/raystack/components/dialog/dialog.module.css
@@ -124,3 +124,11 @@
.footer {
padding: var(--rs-space-5) var(--rs-space-7);
}
+
+/* Reduced transparency: drop the blur, compensate with a heavier solid scrim */
+@media (prefers-reduced-transparency: reduce) {
+ .overlayBlur {
+ backdrop-filter: none;
+ background-color: var(--rs-color-overlay-black-a9);
+ }
+}
diff --git a/packages/raystack/components/drawer/drawer.module.css b/packages/raystack/components/drawer/drawer.module.css
index ba436ea52..9ca413bec 100644
--- a/packages/raystack/components/drawer/drawer.module.css
+++ b/packages/raystack/components/drawer/drawer.module.css
@@ -18,7 +18,7 @@
}
.backdrop[data-ending-style] {
- transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms);
+ transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer));
}
.viewport {
@@ -59,14 +59,21 @@
overscroll-behavior: contain;
touch-action: auto;
will-change: transform;
+ transition: opacity var(--rs-duration-drawer) var(--rs-ease-drawer);
}
.drawerPopup:focus {
outline: none;
}
+.drawerPopup[data-starting-style],
+.drawerPopup[data-ending-style] {
+ opacity: 0;
+}
+
.drawerPopup[data-swiping] {
user-select: none;
+ transition: none;
}
/* Right side drawer */
@@ -76,13 +83,8 @@
transform: translateX(var(--drawer-swipe-movement-x));
}
-.drawerPopup-right[data-starting-style],
-.drawerPopup-right[data-ending-style] {
- transform: translateX(100%);
-}
-
.drawerPopup-right[data-ending-style] {
- transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms);
+ transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer));
}
/* Left side drawer */
@@ -92,13 +94,8 @@
transform: translateX(var(--drawer-swipe-movement-x));
}
-.drawerPopup-left[data-starting-style],
.drawerPopup-left[data-ending-style] {
- transform: translateX(-100%);
-}
-
-.drawerPopup-left[data-ending-style] {
- transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms);
+ transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer));
}
/* Top side drawer */
@@ -108,13 +105,8 @@
transform: translateY(var(--drawer-swipe-movement-y));
}
-.drawerPopup-top[data-starting-style],
.drawerPopup-top[data-ending-style] {
- transform: translateY(-100%);
-}
-
-.drawerPopup-top[data-ending-style] {
- transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms);
+ transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer));
}
/* Bottom side drawer */
@@ -124,13 +116,8 @@
transform: translateY(var(--drawer-swipe-movement-y));
}
-.drawerPopup-bottom[data-starting-style],
.drawerPopup-bottom[data-ending-style] {
- transform: translateY(100%);
-}
-
-.drawerPopup-bottom[data-ending-style] {
- transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms);
+ transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer));
}
.content {
@@ -157,6 +144,11 @@
color: var(--rs-color-foreground-base-primary);
}
+.close:active {
+ background-color: var(--rs-color-background-neutral-secondary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
.close:focus-visible {
outline: 2px solid var(--rs-color-border-base-focus);
outline-offset: -1px;
@@ -202,6 +194,28 @@
.drawerPopup-left,
.drawerPopup-top,
.drawerPopup-bottom {
- transition: transform var(--rs-duration-drawer) var(--rs-ease-drawer);
+ transition:
+ transform var(--rs-duration-drawer) var(--rs-ease-drawer),
+ opacity var(--rs-duration-drawer) var(--rs-ease-drawer);
+ }
+
+ .drawerPopup-right[data-starting-style],
+ .drawerPopup-right[data-ending-style] {
+ transform: translateX(100%);
+ }
+
+ .drawerPopup-left[data-starting-style],
+ .drawerPopup-left[data-ending-style] {
+ transform: translateX(-100%);
+ }
+
+ .drawerPopup-top[data-starting-style],
+ .drawerPopup-top[data-ending-style] {
+ transform: translateY(-100%);
+ }
+
+ .drawerPopup-bottom[data-starting-style],
+ .drawerPopup-bottom[data-ending-style] {
+ transform: translateY(100%);
}
}
diff --git a/packages/raystack/components/field/__tests__/field.test.tsx b/packages/raystack/components/field/__tests__/field.test.tsx
index d4cfab9ea..6ba7cc7a4 100644
--- a/packages/raystack/components/field/__tests__/field.test.tsx
+++ b/packages/raystack/components/field/__tests__/field.test.tsx
@@ -72,13 +72,13 @@ describe('Field', () => {
expect(screen.getByText('Enter a valid email')).toBeInTheDocument();
});
- it('does not render description when error is present', () => {
+ it('keeps description rendered alongside error', () => {
render(
content
);
- expect(screen.queryByText('Enter email')).not.toBeInTheDocument();
+ expect(screen.getByText('Enter email')).toBeInTheDocument();
expect(screen.getByText('Invalid email')).toBeInTheDocument();
});
});
diff --git a/packages/raystack/components/field/field-root.tsx b/packages/raystack/components/field/field-root.tsx
index cf50d97da..d382cb4e4 100644
--- a/packages/raystack/components/field/field-root.tsx
+++ b/packages/raystack/components/field/field-root.tsx
@@ -47,11 +47,13 @@ export function FieldRoot({
{label && {label}}
{children}
{error && (
-
- {error}
-
+
+
+ {error}
+
+
)}
- {!error && description && (
+ {description && (
{description}
diff --git a/packages/raystack/components/field/field.module.css b/packages/raystack/components/field/field.module.css
index 39fee8da2..fffc4cb78 100644
--- a/packages/raystack/components/field/field.module.css
+++ b/packages/raystack/components/field/field.module.css
@@ -28,6 +28,32 @@
margin: 0;
}
+.errorSlot {
+ display: grid;
+ grid-template-rows: 1fr;
+ opacity: 1;
+}
+
+.errorSlot > * {
+ overflow: hidden;
+ min-height: 0;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .errorSlot {
+ transition:
+ grid-template-rows var(--rs-duration-fast) var(--rs-ease-out),
+ opacity var(--rs-duration-fast) var(--rs-ease-out);
+ }
+
+ @starting-style {
+ .errorSlot {
+ grid-template-rows: 0fr;
+ opacity: 0;
+ }
+ }
+}
+
.input {
width: 100%;
padding: 0 var(--rs-space-3);
diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css
index 5ba007416..87c42b4b9 100644
--- a/packages/raystack/components/filter-chip/filter-chip.module.css
+++ b/packages/raystack/components/filter-chip/filter-chip.module.css
@@ -93,10 +93,15 @@
background: var(--rs-color-background-base-primary-hover);
}
+.removeIconContainer:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: -2px;
+}
+
.removeIcon {
cursor: pointer;
color: var(--rs-color-foreground-base-primary);
- transition: color 0.2s ease-in-out;
+ transition: color var(--rs-duration-normal) var(--rs-ease-out);
width: var(--rs-space-4);
height: var(--rs-space-4);
}
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"] {
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 {
diff --git a/packages/raystack/components/icon-button/icon-button.module.css b/packages/raystack/components/icon-button/icon-button.module.css
index 2f45dcdf6..26133e3bb 100644
--- a/packages/raystack/components/icon-button/icon-button.module.css
+++ b/packages/raystack/components/icon-button/icon-button.module.css
@@ -12,17 +12,30 @@
padding: var(--rs-space-1);
}
-.iconButton:hover:not(:disabled),
-.iconButton:active:not(:disabled) {
+.iconButton:hover:not(:disabled) {
background-color: var(--rs-color-background-base-primary-hover);
color: var(--rs-color-foreground-base-primary);
}
+.iconButton:active:not(:disabled) {
+ background-color: var(--rs-color-background-neutral-secondary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.iconButton:active:not(:disabled) {
+ transition: var(--rs-transition-pressed);
+}
+
.iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+.iconButton:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: 1px;
+}
+
.iconButton-size-1 {
width: var(--rs-space-4);
height: var(--rs-space-4);
@@ -33,6 +46,25 @@
height: var(--rs-space-5);
}
+/* Expand sub-24px hit targets to 24px (WCAG 2.5.8); visible size unchanged.
+ size-1: (24 − 12) / 2 = 6px; size-2: (24 − 16) / 2 = 4px. */
+.iconButton-size-1,
+.iconButton-size-2 {
+ position: relative;
+}
+
+.iconButton-size-1::before {
+ content: "";
+ position: absolute;
+ inset: -6px;
+}
+
+.iconButton-size-2::before {
+ content: "";
+ position: absolute;
+ inset: -4px;
+}
+
.iconButton-size-3 {
width: var(--rs-space-6);
height: var(--rs-space-6);
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();
+ 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..012ddf496 100644
--- a/packages/raystack/components/image/image.tsx
+++ b/packages/raystack/components/image/image.tsx
@@ -1,7 +1,13 @@
'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,
+ useLayoutEffect,
+ useRef,
+ useState
+} from 'react';
import styles from './image.module.css';
@@ -36,6 +42,8 @@ export function Image({
radius,
fallback,
onError,
+ onLoad,
+ src,
width,
height,
style,
@@ -43,9 +51,33 @@ export function Image({
decoding = 'async',
...props
}: ImageProps) {
+ const imgRef = useRef(null);
+ const hasFallenBackRef = useRef(false);
+ const [loadState, setLoadState] = useState<'static' | 'loading' | 'loaded'>(
+ 'static'
+ );
+
+ useLayoutEffect(() => {
+ 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 +90,20 @@ export function Image({
return (
-
+
{children}
);
diff --git a/packages/raystack/components/meter/meter.module.css b/packages/raystack/components/meter/meter.module.css
index 6e509c1c6..cf7ea59e1 100644
--- a/packages/raystack/components/meter/meter.module.css
+++ b/packages/raystack/components/meter/meter.module.css
@@ -42,6 +42,8 @@
.indicator {
height: 100%;
background-color: var(--rs-color-background-accent-emphasis);
+ transform: scaleX(calc(var(--rs-meter-percentage, 0) / 100));
+ transform-origin: left;
}
/* Circular variant — viewBox is 72×72, SVG scales to container size */
@@ -86,7 +88,7 @@
@media (prefers-reduced-motion: no-preference) {
.indicator {
- transition: width var(--rs-duration-moderate) linear;
+ transition: transform var(--rs-duration-moderate) linear;
}
.circularIndicatorCircle {
diff --git a/packages/raystack/components/navbar/navbar.module.css b/packages/raystack/components/navbar/navbar.module.css
index 6c513efad..940d8fba7 100644
--- a/packages/raystack/components/navbar/navbar.module.css
+++ b/packages/raystack/components/navbar/navbar.module.css
@@ -11,7 +11,7 @@
@media (prefers-reduced-motion: no-preference) {
.root {
- transition: transform 0.2s ease-in-out;
+ transition: transform var(--rs-duration-normal) var(--rs-ease-in-out);
}
}
@@ -47,3 +47,18 @@
grid-column: 3;
justify-self: end;
}
+
+/* Reduced motion: hide-on-scroll degrades to a quick fade instead of a slide */
+@media (prefers-reduced-motion: reduce) {
+ .root {
+ transition:
+ opacity var(--rs-duration-fast) var(--rs-ease-out),
+ visibility var(--rs-duration-fast) var(--rs-ease-out);
+ }
+
+ .root[data-hidden="true"] {
+ transform: none;
+ opacity: 0;
+ visibility: hidden;
+ }
+}
diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css
index a612349be..9664d6ddc 100644
--- a/packages/raystack/components/number-field/number-field.module.css
+++ b/packages/raystack/components/number-field/number-field.module.css
@@ -23,6 +23,7 @@
color: var(--rs-color-foreground-base-primary);
cursor: pointer;
padding: 0;
+ transition: var(--rs-transition-interactive);
}
.decrement {
@@ -38,6 +39,17 @@
background: var(--rs-color-background-base-primary-hover);
}
+.decrement:focus-visible,
+.increment:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: -2px;
+}
+
+.decrement:active:not([data-disabled]),
+.increment:active:not([data-disabled]) {
+ background: var(--rs-color-background-neutral-secondary);
+}
+
.decrement[data-disabled],
.increment[data-disabled] {
pointer-events: none;
@@ -59,10 +71,25 @@
color: var(--rs-color-foreground-base-primary);
text-align: center;
font-variant-numeric: tabular-nums;
+ transition: var(--rs-transition-interactive);
outline: none;
padding: 0 var(--rs-space-2);
}
+.input:hover {
+ border-color: var(--rs-color-border-base-focus);
+ background: var(--rs-color-background-base-primary-hover);
+}
+
+.input:focus {
+ border-color: var(--rs-color-border-accent-emphasis);
+ background-color: var(--rs-color-background-base-primary);
+}
+
+.input[data-invalid] {
+ border-color: var(--rs-color-border-danger-primary);
+}
+
.input[data-disabled] {
pointer-events: none;
opacity: 0.5;
diff --git a/packages/raystack/components/otp-field/otp-field.module.css b/packages/raystack/components/otp-field/otp-field.module.css
index 0718b6e94..494c67de4 100644
--- a/packages/raystack/components/otp-field/otp-field.module.css
+++ b/packages/raystack/components/otp-field/otp-field.module.css
@@ -46,6 +46,7 @@
.otp-field-input:focus:not([data-readonly]):not([data-invalid]) {
border-color: var(--rs-color-border-accent-emphasis);
+ box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary);
}
.otp-field-input[data-invalid] {
@@ -59,6 +60,10 @@
border-color: var(--rs-color-border-danger-emphasis-hover);
}
+.otp-field-input[data-invalid]:focus:not([data-readonly]) {
+ box-shadow: 0 0 0 2px var(--rs-color-border-danger-primary);
+}
+
.otp-field-input[data-disabled],
.otp-field-input:disabled {
opacity: 0.5;
diff --git a/packages/raystack/components/popover/popover.module.css b/packages/raystack/components/popover/popover.module.css
index 89d75e9f8..88002b498 100644
--- a/packages/raystack/components/popover/popover.module.css
+++ b/packages/raystack/components/popover/popover.module.css
@@ -13,7 +13,7 @@
padding: var(--rs-space-3);
background-color: var(--rs-color-background-base-primary);
border-radius: var(--rs-radius-2);
- box-shadow: var(--rs-shadow-soft);
+ box-shadow: var(--rs-shadow-lifted);
border: 1px solid var(--rs-color-border-base-primary);
color: var(--rs-color-foreground-base-primary);
transform-origin: var(--transform-origin);
diff --git a/packages/raystack/components/preview-card/preview-card.module.css b/packages/raystack/components/preview-card/preview-card.module.css
index 4c74b3755..3e94df13e 100644
--- a/packages/raystack/components/preview-card/preview-card.module.css
+++ b/packages/raystack/components/preview-card/preview-card.module.css
@@ -103,17 +103,34 @@
}
@media (prefers-reduced-motion: no-preference) {
+ /* Default mode (no PreviewCard.Viewport): Base UI positions the positioner
+ via transform (floating-ui floatingStyles). Compositor-only. */
.positioner {
+ transition:
+ transform var(--animation-duration) var(--easing),
+ 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),
diff --git a/packages/raystack/components/progress/progress-track.tsx b/packages/raystack/components/progress/progress-track.tsx
index 2f1e63af1..576d6bd5c 100644
--- a/packages/raystack/components/progress/progress-track.tsx
+++ b/packages/raystack/components/progress/progress-track.tsx
@@ -35,7 +35,10 @@ export function ProgressTrack({
return (
-
+
{children}
);
diff --git a/packages/raystack/components/progress/progress.module.css b/packages/raystack/components/progress/progress.module.css
index 868daefb4..05fd4ec80 100644
--- a/packages/raystack/components/progress/progress.module.css
+++ b/packages/raystack/components/progress/progress.module.css
@@ -42,6 +42,8 @@
.indicator {
height: 100%;
background-color: var(--rs-color-background-accent-emphasis);
+ transform: scaleX(calc(var(--rs-progress-percentage, 0) / 100));
+ transform-origin: left;
}
/* Circular variant — viewBox is 72×72, SVG scales to container size */
@@ -86,7 +88,7 @@
@media (prefers-reduced-motion: no-preference) {
.indicator {
- transition: width var(--rs-duration-moderate) linear;
+ transition: transform var(--rs-duration-moderate) linear;
}
.circularIndicatorCircle {
@@ -103,3 +105,50 @@
text-align: center;
white-space: nowrap;
}
+
+/* Indeterminate (value={null}). Static fallback: dimmed centered bar / partial arc. */
+.indicator[data-indeterminate] {
+ transform: scaleX(0.4);
+ transform-origin: center;
+ opacity: 0.6;
+}
+
+.circularSvg[data-indeterminate] .circularIndicatorCircle {
+ stroke-dashoffset: calc(var(--rs-progress-circumference) * 0.75);
+ opacity: 0.6;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .indicator[data-indeterminate] {
+ opacity: 1;
+ transform-origin: left;
+ transition: none;
+ animation: progress-indeterminate-sweep 1.2s var(--rs-ease-in-out) infinite;
+ }
+
+ .circularSvg[data-indeterminate] {
+ animation: progress-indeterminate-rotate 1.2s linear infinite;
+ }
+
+ .circularSvg[data-indeterminate] .circularIndicatorCircle {
+ opacity: 1;
+ }
+}
+
+@keyframes progress-indeterminate-sweep {
+ from {
+ transform: translateX(-45%) scaleX(0.4);
+ }
+ to {
+ transform: translateX(105%) scaleX(0.4);
+ }
+}
+
+@keyframes progress-indeterminate-rotate {
+ from {
+ transform: rotate(-90deg);
+ }
+ to {
+ transform: rotate(270deg);
+ }
+}
diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css
index 53562f1c5..634d236da 100644
--- a/packages/raystack/components/radio/radio.module.css
+++ b/packages/raystack/components/radio/radio.module.css
@@ -20,7 +20,9 @@
justify-content: center;
cursor: pointer;
box-sizing: border-box;
- transition: background-color 150ms ease, border-color 150ms ease;
+ transition:
+ background-color var(--rs-duration-fast) var(--rs-ease-out),
+ border-color var(--rs-duration-fast) var(--rs-ease-out);
}
/* Group-level size — low specificity via :where() so item-level size wins */
@@ -40,11 +42,43 @@
min-height: var(--rs-space-4);
}
+/* Expand the small radio's 12px hit target to 24px (WCAG 2.5.8).
+ (24px − 12px) / 2 = 6px per side. Visible size is unchanged. */
+:where(.group-small) .radioitem,
+.radioitem.small {
+ position: relative;
+}
+
+:where(.group-small) .radioitem::before,
+.radioitem.small::before {
+ content: "";
+ position: absolute;
+ inset: -6px;
+}
+
.radioitem:hover {
border-color: var(--rs-color-border-base-tertiary-hover);
background: var(--rs-color-background-base-primary-hover);
}
+.radioitem:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: 1px;
+}
+
+.radioitem:active:not([data-disabled]) {
+ transform: scale(0.92);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .radioitem {
+ transition:
+ background-color var(--rs-duration-fast) var(--rs-ease-out),
+ border-color var(--rs-duration-fast) var(--rs-ease-out),
+ transform var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
.radioitem[data-checked] {
border: 1px solid var(--rs-color-background-accent-emphasis);
background: var(--rs-color-background-accent-emphasis);
diff --git a/packages/raystack/components/scroll-area/scroll-area.module.css b/packages/raystack/components/scroll-area/scroll-area.module.css
index 10395305f..8c40cccac 100644
--- a/packages/raystack/components/scroll-area/scroll-area.module.css
+++ b/packages/raystack/components/scroll-area/scroll-area.module.css
@@ -1,6 +1,6 @@
:root {
- --rs-scrollbar-size-default: var(--rs-space-2);
- --rs-scrollbar-size-hover: 6px;
+ --rs-scrollbar-size-default: var(--rs-space-2); /* 4px — resting thumb size */
+ --rs-scrollbar-size-hover: 6px; /* expanded thumb; also the constant hit area */
}
.root {
@@ -25,23 +25,17 @@
background: transparent;
pointer-events: auto;
position: relative;
- transition: opacity 150ms ease-out;
+ transition: opacity var(--rs-duration-fast) var(--rs-ease-out);
}
+/* Constant size = constant hit area; the track is transparent so the extra
+ 2px of gutter is invisible. Hover no longer changes layout. */
.scrollbar[data-orientation="vertical"] {
- width: var(--rs-scrollbar-size-default);
-}
-
-.scrollbar[data-orientation="vertical"]:hover {
width: var(--rs-scrollbar-size-hover);
}
.scrollbar[data-orientation="horizontal"] {
flex-direction: column;
- height: var(--rs-scrollbar-size-default);
-}
-
-.scrollbar[data-orientation="horizontal"]:hover {
height: var(--rs-scrollbar-size-hover);
}
@@ -49,18 +43,45 @@
flex: 1;
background: var(--rs-color-overlay-base-a5);
border-radius: var(--rs-radius-2);
- transition: opacity 150ms ease-out;
+ transition:
+ opacity var(--rs-duration-fast) var(--rs-ease-out),
+ background-color var(--rs-duration-fast) var(--rs-ease-out);
opacity: 1;
pointer-events: auto;
touch-action: none;
}
+/* Resting: thumb visually --rs-scrollbar-size-default (4px) inside the 6px
+ gutter — 0.667 = 4px / 6px. Anchored to the outer edge, grows inward. */
+.scrollbar[data-orientation="vertical"] .thumb {
+ transform: scaleX(0.667);
+ transform-origin: 100% 50%;
+}
+
+.scrollbar[data-orientation="horizontal"] .thumb {
+ transform: scaleY(0.667);
+ transform-origin: 50% 100%;
+}
+
+/* Hover/drag: full size, one overlay step deeper per state. Base UI exposes
+ no data-pressed; pointer capture keeps :active held for the whole drag. */
+.scrollbar:hover .thumb {
+ transform: none;
+ background: var(--rs-color-overlay-base-a6);
+}
+
+.thumb:active,
+.scrollbar:active .thumb {
+ transform: none;
+ background: var(--rs-color-overlay-base-a7);
+}
+
@media (prefers-reduced-motion: no-preference) {
- .scrollbar {
+ .thumb {
transition:
- width 150ms ease-out,
- height 150ms ease-out,
- opacity 150ms ease-out;
+ opacity var(--rs-duration-fast) var(--rs-ease-out),
+ background-color var(--rs-duration-fast) var(--rs-ease-out),
+ transform var(--rs-duration-fast) var(--rs-ease-out);
}
}
diff --git a/packages/raystack/components/select/select-item.tsx b/packages/raystack/components/select/select-item.tsx
index 9a7c52797..140e80994 100644
--- a/packages/raystack/components/select/select-item.tsx
+++ b/packages/raystack/components/select/select-item.tsx
@@ -6,12 +6,28 @@ import {
} from '@base-ui/react';
import { cx } from 'class-variance-authority';
import { ReactNode, useLayoutEffect } from 'react';
-import { Checkbox } from '../checkbox';
import { getMatch } from '../menu/utils';
import { Text } from '../text';
import styles from './select.module.css';
import { useSelectContext } from './select-root';
+const CheckMarkIcon = () => (
+
+);
+
export interface SelectItemProps extends SelectPrimitive.Item.Props {
leadingIcon?: ReactNode;
}
@@ -76,7 +92,15 @@ export function SelectItem({
{...props}
render={(renderProps, state) => (
- {multiple && }
+ {multiple && (
+
+ {state.selected && }
+
+ )}
{element}
)}
diff --git a/packages/raystack/components/select/select.module.css b/packages/raystack/components/select/select.module.css
index 8fb9297cd..911df9c8b 100644
--- a/packages/raystack/components/select/select.module.css
+++ b/packages/raystack/components/select/select.module.css
@@ -1,6 +1,5 @@
.positioner {
z-index: var(--rs-z-index-portal);
- background-color: var(--rs-color-background-base-primary);
}
.content {
@@ -66,6 +65,30 @@
pointer-events: none;
}
+.checkIndicator {
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: var(--rs-space-5);
+ height: var(--rs-space-5);
+ border-radius: var(--rs-radius-1);
+ background: var(--rs-color-background-base-primary);
+ border: 1px solid var(--rs-color-border-base-secondary);
+ color: var(--rs-color-foreground-accent-emphasis);
+}
+
+.checkIndicator[data-checked] {
+ background: var(--rs-color-background-accent-emphasis);
+ border: none;
+}
+
+.checkIndicator svg {
+ width: 100%;
+ height: 100%;
+}
+
.label {
padding: var(--rs-space-2) var(--rs-space-3);
font-weight: var(--rs-font-weight-medium);
@@ -153,6 +176,10 @@
color: var(--rs-color-foreground-base-primary);
}
+.trigger:active:not([data-disabled]):not(:disabled) {
+ background-color: var(--rs-color-background-neutral-secondary);
+}
+
.trigger:focus-visible {
outline: 1px solid var(--rs-color-border-accent-emphasis);
}
@@ -181,6 +208,16 @@
color: var(--rs-color-foreground-base-secondary);
}
+.trigger[data-popup-open] .triggerIcon {
+ transform: rotate(180deg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .triggerIcon {
+ transition: transform var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
.content[data-variant="text"] {
margin-top: var(--rs-space-2);
padding: var(--rs-space-1);
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 {
diff --git a/packages/raystack/components/side-panel/side-panel.tsx b/packages/raystack/components/side-panel/side-panel.tsx
index 27d904863..1cf6050aa 100644
--- a/packages/raystack/components/side-panel/side-panel.tsx
+++ b/packages/raystack/components/side-panel/side-panel.tsx
@@ -20,6 +20,12 @@ interface SidePanelProps
extends ComponentProps<'aside'>,
VariantProps {}
+/**
+ * SidePanel is persistent layout chrome (like Sidebar), not an overlay:
+ * it intentionally has no enter/exit animation. If you conditionally mount
+ * it, it will appear/disappear instantly — prefer keeping it mounted, or use
+ * Drawer/Dialog for transient surfaces.
+ */
const SidePanelRoot = ({
side = 'right',
className,
diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css
index 676cc412c..fb58815ff 100644
--- a/packages/raystack/components/sidebar/sidebar.module.css
+++ b/packages/raystack/components/sidebar/sidebar.module.css
@@ -61,8 +61,18 @@
align-items: flex-start;
}
+.root [data-collapse-hidden] {
+ transition:
+ opacity 0.2s ease,
+ visibility 0.2s ease;
+}
+
.root[data-closed] [data-collapse-hidden] {
- display: none;
+ width: 0;
+ min-width: 0;
+ overflow: hidden;
+ opacity: 0;
+ visibility: hidden;
}
.footer {
@@ -82,12 +92,7 @@
color: var(--rs-color-foreground-base-secondary);
cursor: pointer;
text-decoration: none;
- transition:
- background-color 0.2s ease,
- color 0.2s ease,
- border-color 0.2s ease,
- box-shadow 0.2s ease,
- opacity 0.2s ease;
+ transition: var(--rs-transition-interactive);
box-sizing: border-box;
}
@@ -113,6 +118,11 @@
color: var(--rs-color-foreground-base-primary);
}
+.nav-item:active:not([data-disabled="true"]) {
+ background-color: var(--rs-color-background-neutral-secondary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
/* keep item highlighted when menu is open */
.more-trigger[aria-expanded="true"] {
background-color: var(--rs-color-background-base-primary-hover);
@@ -201,13 +211,16 @@
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
- transition: opacity 0.2s ease;
+ transition:
+ opacity var(--rs-duration-normal) var(--rs-ease-out),
+ visibility var(--rs-duration-normal) var(--rs-ease-out);
}
.root[data-closed] .nav-text {
width: 0;
+ min-width: 0;
opacity: 0;
- display: none;
+ visibility: hidden;
}
.resizeHandle {
@@ -216,8 +229,8 @@
right: 0;
width: var(--rs-space-2);
height: 100%;
- cursor: ew-resize;
- transition: background-color 0.2s ease;
+ cursor: pointer;
+ transition: background-color var(--rs-duration-normal) var(--rs-ease-out);
transform: translateX(50%);
}
@@ -232,7 +245,6 @@
.root[data-position="right"] .resizeHandle {
left: 0;
right: auto;
- cursor: ew-resize;
transform: translateX(-50%);
}
@@ -304,7 +316,7 @@
height: var(--rs-space-4);
color: var(--rs-color-foreground-base-secondary);
transform: rotate(-90deg);
- transition: transform 0.2s ease;
+ transition: transform var(--rs-duration-normal) var(--rs-ease-out);
flex-shrink: 0;
}
@@ -319,7 +331,7 @@
padding: var(--rs-space-2) var(--rs-space-3);
color: var(--rs-color-foreground-base-secondary);
opacity: 0;
- transition: opacity 0.2s ease;
+ transition: opacity var(--rs-duration-normal) var(--rs-ease-out);
}
.nav-group-header:hover .nav-group-trailing-icon {
@@ -380,10 +392,7 @@
}
@media (prefers-reduced-motion: no-preference) {
- .root,
- .nav-item,
- .nav-text,
- .resizeHandle {
+ .root {
transition: width 0.2s ease;
}
diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css
index 40b98b19b..bbdd714f1 100644
--- a/packages/raystack/components/slider/slider.module.css
+++ b/packages/raystack/components/slider/slider.module.css
@@ -53,8 +53,10 @@
outline: none;
}
-.thumb:hover {
- border-color: var(--rs-color-border-accent-emphasis-hover);
+.thumb:focus-visible .thumbLarge,
+.thumb:focus-visible .thumbSmall {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: 1px;
}
.thumbLarge {
@@ -87,11 +89,45 @@
box-shadow: var(--rs-shadow-soft);
}
+/* Expand the small thumb's 8×16px hit target to 24×24px (WCAG 2.5.8).
+ Horizontal: (24 − 8) / 2 = 8px; vertical: (24 − 16) / 2 = 4px.
+ Pointer events bubble from the pseudo through .thumbSmall to the
+ Base UI thumb element, so grabbing works from the expanded area. */
+.thumbSmall {
+ position: relative;
+}
+
+.thumbSmall::before {
+ content: "";
+ position: absolute;
+ top: -4px;
+ bottom: -4px;
+ left: -8px;
+ right: -8px;
+}
+
.thumb:hover .thumbLarge,
.thumb:hover .thumbSmall {
background-color: var(--rs-color-background-base-secondary);
}
+/* Grabbing: the thumb grows slightly and lifts. Scale lives on the child
+ so the thumb's track position (set inline by Base UI) is never animated. */
+.thumb:active .thumbLarge,
+.thumb:active .thumbSmall {
+ transform: scale(1.08);
+ box-shadow: var(--rs-shadow-lifted);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .thumbLarge,
+ .thumbSmall {
+ transition:
+ transform var(--rs-duration-fast) var(--rs-ease-out),
+ box-shadow var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
.label {
position: absolute;
top: calc(-1 * (var(--rs-space-8) + 1px));
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;
}
}
diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css
index 5a7e3853d..ba0dd61c5 100644
--- a/packages/raystack/components/switch/switch.module.css
+++ b/packages/raystack/components/switch/switch.module.css
@@ -9,7 +9,12 @@
cursor: pointer;
background: var(--rs-color-background-neutral-secondary);
border-radius: var(--rs-radius-full);
- transition: background-color 200ms ease;
+ transition: background-color var(--rs-duration-normal) var(--rs-ease-out);
+}
+
+.switch:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: 1px;
}
.switch.large {
@@ -56,7 +61,7 @@
@media (prefers-reduced-motion: no-preference) {
.thumb {
- transition: transform 200ms ease-in-out;
+ transition: transform var(--rs-duration-normal) var(--rs-ease-in-out);
}
}
@@ -74,3 +79,20 @@
.switch.small[data-checked] .thumb {
transform: translateX(var(--rs-space-4));
}
+
+/* Pressed: the thumb stretches toward the track center (iOS behavior).
+ transform-origin only affects the scale; the translateX values must
+ exactly mirror the resting rules above. */
+.switch:active:not([data-disabled]) .thumb {
+ transform: translateX(var(--rs-space-1)) scaleX(1.15);
+ transform-origin: left center;
+}
+
+.switch[data-checked]:active:not([data-disabled]) .thumb {
+ transform: translateX(var(--rs-space-5)) scaleX(1.15);
+ transform-origin: right center;
+}
+
+.switch.small[data-checked]:active:not([data-disabled]) .thumb {
+ transform: translateX(var(--rs-space-4)) scaleX(1.15);
+}
diff --git a/packages/raystack/components/table/table.module.css b/packages/raystack/components/table/table.module.css
index 93a463865..363dda9ff 100644
--- a/packages/raystack/components/table/table.module.css
+++ b/packages/raystack/components/table/table.module.css
@@ -66,3 +66,52 @@
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);
+}
+
+/* Scrolled-under header edge: the hairline under sticky header cells is drawn
+ only once content has scrolled beneath them, fading in across the first
+ 24px of scroll. Tracks scroll 1:1 (state, not motion) so it applies under
+ reduced motion too. Browsers without scroll-driven animation support keep
+ the static border declared on .head. */
+@supports (animation-timeline: scroll()) {
+ .head {
+ border-bottom-color: transparent;
+ animation: header-edge linear both;
+ animation-timeline: scroll(nearest block);
+ animation-range: 0px 24px;
+ }
+}
+
+@keyframes header-edge {
+ to {
+ border-bottom-color: var(--rs-color-border-base-primary);
+ }
+}
diff --git a/packages/raystack/components/table/table.tsx b/packages/raystack/components/table/table.tsx
index a92065fd4..ded5b687e 100644
--- a/packages/raystack/components/table/table.tsx
+++ b/packages/raystack/components/table/table.tsx
@@ -25,8 +25,19 @@ function TableBody({ ...props }: ComponentProps<'tbody'>) {
}
TableBody.displayName = 'Table.Body';
-function TableRow({ ...props }: ComponentProps<'tr'>) {
- return ;
+const row = cva(styles['row'], {
+ variants: {
+ interactive: {
+ true: styles['row-interactive']
+ }
+ }
+});
+function TableRow({
+ className,
+ interactive,
+ ...props
+}: ComponentProps<'tr'> & VariantProps) {
+ return ;
}
TableRow.displayName = 'Table.Row';
diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css
index ec0b76d9a..9283e8885 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,17 @@
pointer-events: none;
}
+.trigger:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: 1px;
+}
+
+/* Pressed: one step darker than the list surface. Applies instantly
+ (no background transition on the trigger) — correct for a press. */
+.trigger:active:not([data-disabled]):not([data-active]) {
+ background-color: var(--rs-color-background-neutral-secondary-hover);
+}
+
.trigger-icon {
display: inline-flex;
width: var(--rs-space-5);
@@ -127,9 +138,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 +164,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 */
@@ -161,6 +185,8 @@
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
+/* Plain variant is text-only: dim on press instead of painting a box. */
+.variant-plain .trigger:active:not([data-disabled]):not([data-active]) {
+ background-color: transparent;
+ opacity: 0.7;
+}
diff --git a/packages/raystack/components/text-area/text-area.module.css b/packages/raystack/components/text-area/text-area.module.css
index f7bde8a0c..59b242b20 100644
--- a/packages/raystack/components/text-area/text-area.module.css
+++ b/packages/raystack/components/text-area/text-area.module.css
@@ -87,3 +87,7 @@
border-color: transparent;
outline: none;
}
+
+.variant-borderless:focus-visible:not(:disabled) {
+ box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary);
+}
diff --git a/packages/raystack/components/text/__tests__/text.test.tsx b/packages/raystack/components/text/__tests__/text.test.tsx
index 725e7bf5f..b02fcfc10 100644
--- a/packages/raystack/components/text/__tests__/text.test.tsx
+++ b/packages/raystack/components/text/__tests__/text.test.tsx
@@ -169,7 +169,7 @@ describe('Text', () => {
expect(container.firstChild).toHaveClass(styles['text-strike-through']);
expect(container.firstChild).toHaveClass(styles['text-underline']);
expect(container.firstChild).toHaveClass(
- styles['text-italic-strike-through']
+ styles['text-underline-strike-through']
);
});
});
diff --git a/packages/raystack/components/text/text.module.css b/packages/raystack/components/text/text.module.css
index eaa577886..755e1d77b 100644
--- a/packages/raystack/components/text/text.module.css
+++ b/packages/raystack/components/text/text.module.css
@@ -138,7 +138,7 @@
text-decoration-line: line-through;
}
-.text-italic-strike-through {
+.text-underline-strike-through {
text-decoration-line: underline line-through;
}
diff --git a/packages/raystack/components/text/text.tsx b/packages/raystack/components/text/text.tsx
index 63470d466..fcd72a99f 100644
--- a/packages/raystack/components/text/text.tsx
+++ b/packages/raystack/components/text/text.tsx
@@ -62,7 +62,7 @@ export const textVariants = cva(styles.text, {
{
strikeThrough: true,
underline: true,
- className: styles['text-italic-strike-through']
+ className: styles['text-underline-strike-through']
}
]
});
diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css
index 7183796d7..3c531dbdc 100644
--- a/packages/raystack/components/toast/toast.module.css
+++ b/packages/raystack/components/toast/toast.module.css
@@ -167,6 +167,10 @@
transform: translateY(calc(-100% - var(--rs-space-7)));
}
+.root[data-starting-style] {
+ opacity: 0;
+}
+
/* ===== Limited state ===== */
.root[data-limited] {
opacity: 0;
@@ -207,7 +211,7 @@
/* ===== Content (stacking visibility) ===== */
.content {
- transition: opacity 250ms;
+ transition: opacity var(--rs-duration-moderate) var(--rs-ease-out);
}
.content[data-behind] {
diff --git a/packages/raystack/components/toggle/toggle.module.css b/packages/raystack/components/toggle/toggle.module.css
index 7cc8a4d12..db187515f 100644
--- a/packages/raystack/components/toggle/toggle.module.css
+++ b/packages/raystack/components/toggle/toggle.module.css
@@ -6,7 +6,7 @@
color: var(--rs-color-foreground-base-secondary);
cursor: pointer;
padding: var(--rs-space-1);
- transition: color 0.2s ease;
+ transition: color var(--rs-duration-normal) var(--rs-ease-out);
background-color: var(--rs-color-background-base-secondary);
border: 0.5px solid var(--rs-color-border-base-primary);
border-radius: var(--rs-radius-1);
@@ -24,21 +24,72 @@
height: 100%;
}
-.toggle:hover,
+@media (prefers-reduced-motion: no-preference) {
+ .toggleContent {
+ transition:
+ background-color var(--rs-duration-fast) var(--rs-ease-out),
+ box-shadow var(--rs-duration-fast) var(--rs-ease-out);
+ }
+
+ .group .toggle {
+ /* color entry matches .toggle's own transition (line 9) so both are
+ driven by the same token */
+ transition:
+ color var(--rs-duration-normal) var(--rs-ease-out),
+ background-color var(--rs-duration-fast) var(--rs-ease-out),
+ box-shadow var(--rs-duration-fast) var(--rs-ease-out);
+ }
+}
+
+.toggle:hover {
+ color: var(--rs-color-foreground-base-primary);
+}
+.toggle:hover .toggleContent {
+ background-color: var(--rs-color-background-base-primary-hover);
+}
+
.toggle[data-pressed] {
color: var(--rs-color-foreground-base-primary);
}
-.toggle:hover .toggleContent,
.toggle[data-pressed] .toggleContent {
- background-color: var(--rs-color-background-base-primary-hover);
+ background-color: var(--rs-color-background-neutral-secondary);
+ box-shadow: var(--rs-shadow-inset);
+}
+.toggle[data-pressed]:hover .toggleContent {
+ background-color: var(--rs-color-background-neutral-secondary-hover);
}
.toggle[data-disabled] {
opacity: 0.5;
- pointer-events: none;
+ pointer-events: initial;
cursor: not-allowed;
}
+.toggle[data-disabled]:not([data-pressed]):hover {
+ color: var(--rs-color-foreground-base-secondary);
+}
+
+.toggle[data-disabled]:not([data-pressed]):hover .toggleContent {
+ background-color: transparent;
+ box-shadow: none;
+}
+
+.group .toggle[data-disabled]:not([data-pressed]):hover {
+ background-color: transparent;
+ box-shadow: none;
+}
+
+.toggle:focus-visible {
+ outline: 2px solid var(--rs-color-border-accent-emphasis);
+ outline-offset: 1px;
+}
+
+/* Inside a group the container clips overflow and items sit 1px apart:
+ keep the ring fully inside the item. */
+.group .toggle:focus-visible {
+ outline-offset: -2px;
+}
+
.size-1 {
width: var(--rs-space-4);
height: var(--rs-space-4);
@@ -49,6 +100,27 @@
height: var(--rs-space-5);
}
+/* Expand sub-24px hit targets to 24px (WCAG 2.5.8); visible size unchanged.
+ size-1: (24 − 12) / 2 = 6px; size-2: (24 − 16) / 2 = 4px.
+ Inside .group (overflow: clip, 1px gaps) the expansion is clipped at the
+ group edge — that is a known limit; do not change the group's overflow. */
+.size-1,
+.size-2 {
+ position: relative;
+}
+
+.size-1::before {
+ content: "";
+ position: absolute;
+ inset: -6px;
+}
+
+.size-2::before {
+ content: "";
+ position: absolute;
+ inset: -4px;
+}
+
.size-3 {
width: var(--rs-space-6);
height: var(--rs-space-6);
@@ -79,8 +151,7 @@
}
.group .toggle:hover {
- background-color: var(--rs-color-background-base-primary);
- box-shadow: var(--rs-shadow-inset);
+ background-color: var(--rs-color-background-base-primary-hover);
}
.group .toggle[data-pressed] {
@@ -95,6 +166,6 @@
.group[data-disabled] {
opacity: 0.5;
- pointer-events: none;
+ pointer-events: initial;
cursor: not-allowed;
}
diff --git a/packages/raystack/components/toolbar/toolbar.module.css b/packages/raystack/components/toolbar/toolbar.module.css
index e9b76ca5f..02b1b44af 100644
--- a/packages/raystack/components/toolbar/toolbar.module.css
+++ b/packages/raystack/components/toolbar/toolbar.module.css
@@ -37,5 +37,5 @@
}
.group {
padding: 0;
- background-color: none;
+ background-color: transparent;
}
diff --git a/packages/raystack/components/tooltip/tooltip.module.css b/packages/raystack/components/tooltip/tooltip.module.css
index d7cac3666..9639d967a 100644
--- a/packages/raystack/components/tooltip/tooltip.module.css
+++ b/packages/raystack/components/tooltip/tooltip.module.css
@@ -21,7 +21,7 @@
letter-spacing: var(--rs-letter-spacing-mini);
width: fit-content;
max-width: 400px;
- transform-origin: var(--transform-origin);
+ transition: opacity var(--rs-duration-fast) var(--rs-ease-out);
}
.arrow svg {
@@ -55,107 +55,68 @@
transform: translateY(-50%) translateX(-100%) rotate(90deg);
}
-@keyframes fade {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
+.content[data-starting-style],
+.content[data-ending-style] {
+ opacity: 0;
}
-@keyframes slideUpAndFade {
- from {
- opacity: 0;
- transform: translateY(var(--rs-space-1));
- }
+
+/* Base UI sets data-instant (focus/click/dismiss and grouped-trigger moves):
+ skip the animation but keep the transition machinery. */
+.content[data-instant] {
+ transition-duration: 0ms;
}
-@keyframes slideRightAndFade {
- from {
- opacity: 0;
- transform: translateX(calc(-1 * var(--rs-space-1)));
+
+@media (prefers-reduced-motion: no-preference) {
+ .content {
+ transition:
+ opacity var(--rs-duration-fast) var(--rs-ease-out),
+ transform var(--rs-duration-fast) var(--rs-ease-out);
}
-}
-@keyframes slideDownAndFade {
- from {
- opacity: 0;
+ /* Per-side directional offset — same vectors as the old keyframes */
+ .content[data-side="top"][data-starting-style],
+ .content[data-side="top"][data-ending-style] {
transform: translateY(calc(-1 * var(--rs-space-1)));
}
-}
-@keyframes slideLeftAndFade {
- from {
- opacity: 0;
+ .content[data-side="right"][data-starting-style],
+ .content[data-side="right"][data-ending-style] {
transform: translateX(var(--rs-space-1));
}
-}
-@keyframes slideDownRightAndFade {
- from {
- opacity: 0;
+ .content[data-side="bottom"][data-starting-style],
+ .content[data-side="bottom"][data-ending-style] {
+ transform: translateY(var(--rs-space-1));
+ }
+
+ .content[data-side="left"][data-starting-style],
+ .content[data-side="left"][data-ending-style] {
+ transform: translateX(calc(-1 * var(--rs-space-1)));
+ }
+
+ /* Aligned variants get a diagonal offset — same vectors as before.
+ Higher specificity (0,4,0) than the side rules (0,3,0), so they win
+ when data-align is start/end. */
+ .content[data-side="top"][data-align="start"][data-starting-style],
+ .content[data-side="top"][data-align="start"][data-ending-style] {
transform: translate(
calc(-1 * var(--rs-space-1)),
calc(-1 * var(--rs-space-1))
);
}
-}
-@keyframes slideDownLeftAndFade {
- from {
- opacity: 0;
+ .content[data-side="top"][data-align="end"][data-starting-style],
+ .content[data-side="top"][data-align="end"][data-ending-style] {
transform: translate(var(--rs-space-1), calc(-1 * var(--rs-space-1)));
}
-}
-@keyframes slideUpRightAndFade {
- from {
- opacity: 0;
+ .content[data-side="bottom"][data-align="start"][data-starting-style],
+ .content[data-side="bottom"][data-align="start"][data-ending-style] {
transform: translate(calc(-1 * var(--rs-space-1)), var(--rs-space-1));
}
-}
-@keyframes slideUpLeftAndFade {
- from {
- opacity: 0;
+ .content[data-side="bottom"][data-align="end"][data-starting-style],
+ .content[data-side="bottom"][data-align="end"][data-ending-style] {
transform: translate(var(--rs-space-1), var(--rs-space-1));
}
}
-
-@media (prefers-reduced-motion: no-preference) {
- .content {
- animation-duration: var(--rs-duration-fast);
- animation-timing-function: var(--rs-ease-out);
- }
-
- .content[data-open][data-side="top"]:not([data-instant]) {
- animation-name: slideDownAndFade;
- }
-
- .content[data-open][data-side="right"]:not([data-instant]) {
- animation-name: slideLeftAndFade;
- }
-
- .content[data-open][data-side="bottom"]:not([data-instant]) {
- animation-name: slideUpAndFade;
- }
-
- .content[data-open][data-side="left"]:not([data-instant]) {
- animation-name: slideRightAndFade;
- }
-
- .content[data-side="top"][data-align="start"]:not([data-instant]) {
- animation-name: slideDownRightAndFade;
- }
-
- .content[data-side="top"][data-align="end"]:not([data-instant]) {
- animation-name: slideDownLeftAndFade;
- }
-
- .content[data-side="bottom"][data-align="start"]:not([data-instant]) {
- animation-name: slideUpRightAndFade;
- }
-
- .content[data-side="bottom"][data-align="end"]:not([data-instant]) {
- animation-name: slideUpLeftAndFade;
- }
-}
diff --git a/packages/raystack/components/tour-beta/tour.module.css b/packages/raystack/components/tour-beta/tour.module.css
index 7d16946b2..70caa62c8 100644
--- a/packages/raystack/components/tour-beta/tour.module.css
+++ b/packages/raystack/components/tour-beta/tour.module.css
@@ -52,7 +52,7 @@
otherwise the first open would animate in from the viewport origin. */
@media (prefers-reduced-motion: no-preference) {
.positioner:has(.popup:not([data-starting-style])) {
- transition: transform 350ms var(--rs-ease-out);
+ transition: transform var(--rs-duration-slow) var(--rs-ease-out);
}
}
diff --git a/packages/raystack/components/tour/__tests__/tour.test.tsx b/packages/raystack/components/tour/__tests__/tour.test.tsx
index 51b548a99..b1da384c8 100644
--- a/packages/raystack/components/tour/__tests__/tour.test.tsx
+++ b/packages/raystack/components/tour/__tests__/tour.test.tsx
@@ -324,6 +324,11 @@ describe('Tour', () => {
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
);
+ // The overlay itself holds for one exit fade after a real close — wait
+ // for it to fully unmount before the next assertions rely on its absence.
+ await waitFor(() =>
+ expect(document.querySelector('[data-status]')).not.toBeInTheDocument()
+ );
fireEvent.click(screen.getByText('unmount'));
// Resume onto the now-missing target.
diff --git a/packages/raystack/components/tour/tour-overlay.tsx b/packages/raystack/components/tour/tour-overlay.tsx
index a235d6cb0..c4be329ee 100644
--- a/packages/raystack/components/tour/tour-overlay.tsx
+++ b/packages/raystack/components/tour/tour-overlay.tsx
@@ -1,12 +1,17 @@
'use client';
import { cx } from 'class-variance-authority';
-import { type ComponentPropsWithoutRef, useEffect, useState } from 'react';
+import {
+ type ComponentPropsWithoutRef,
+ useEffect,
+ useRef,
+ useState
+} from 'react';
import { createPortal } from 'react-dom';
import styles from './tour.module.css';
import { useTourContext } from './tour-context';
import { usePrefersReducedMotion } from './use-prefers-reduced-motion';
-import { rectsEqual, type SpotlightRect } from './utils';
+import { FADE_OUT_MS, rectsEqual, type SpotlightRect } from './utils';
export interface TourOverlayProps extends ComponentPropsWithoutRef<'div'> {
/**
@@ -108,9 +113,74 @@ export function TourOverlay({
if (revealed) setEntered(true);
}, [open, disabled, revealed]);
- if (!open || !mounted || disabled || !step) return null;
+ const active = open && !disabled && step != null;
// Requiring the live `spotlightElement` prevents an orphaned dim after a target unmounts.
- if (targeted && (!spotlightElement || !displayed || !rect)) return null;
+ const orphaned =
+ active && targeted && (!spotlightElement || !displayed || !rect);
+
+ // Frozen copy of the last-painted dim so it can fade out in place after close.
+ const exitFrameRef = useRef<{
+ hole: { x: number; y: number; width: number; height: number } | null;
+ radius: number;
+ } | null>(null);
+
+ const [exiting, setExiting] = useState(false);
+ // Tracks whether we last painted a real, live spotlight (not an orphaned
+ // gap) — only that state has a valid frame worth fading out from.
+ const wasShowingRef = useRef(false);
+ useEffect(() => {
+ if (active && !orphaned) {
+ wasShowingRef.current = true;
+ setExiting(false);
+ return;
+ }
+ if (orphaned) {
+ // Mid-tour orphan: hide instantly (no stale frame to fade from later).
+ wasShowingRef.current = false;
+ setExiting(false);
+ return;
+ }
+ if (!wasShowingRef.current) return;
+ wasShowingRef.current = false;
+ if (reducedMotion) return; // exits are instant under reduced motion
+ setExiting(true);
+ const timer = setTimeout(() => setExiting(false), FADE_OUT_MS);
+ return () => clearTimeout(timer);
+ }, [active, orphaned, reducedMotion]);
+
+ if (!mounted) return null;
+ if (!active) {
+ // Closing: hold the dim for one fade, mirroring the entry.
+ if (!exiting || exitFrameRef.current == null) return null;
+ const frame = exitFrameRef.current;
+ return createPortal(
+ ,
+ document.body
+ );
+ }
+ if (orphaned) return null;
const padding = step.spotlightPadding ?? spotlightPadding;
const radius = step.spotlightRadius ?? spotlightRadius;
@@ -126,6 +196,8 @@ export function TourOverlay({
}
: null;
+ exitFrameRef.current = { hole, radius };
+
const holeOpen = fade ? shown : true;
return createPortal(
diff --git a/packages/raystack/components/tour/tour-root.tsx b/packages/raystack/components/tour/tour-root.tsx
index 08d2a6c9b..313369e24 100644
--- a/packages/raystack/components/tour/tour-root.tsx
+++ b/packages/raystack/components/tour/tour-root.tsx
@@ -24,10 +24,7 @@ import type {
} from './types';
import { usePrefersReducedMotion } from './use-prefers-reduced-motion';
import { resolveTourTarget, useTourTarget } from './use-tour-target';
-import { rectsEqual } from './utils';
-
-// Must match the overlay's opacity transition in `tour.module.css`.
-const FADE_OUT_MS = 160;
+import { FADE_OUT_MS, rectsEqual } from './utils';
const REVEAL_TIMEOUT_MS = 2000;
diff --git a/packages/raystack/components/tour/tour.module.css b/packages/raystack/components/tour/tour.module.css
index 6823972ff..3d7ab44a7 100644
--- a/packages/raystack/components/tour/tour.module.css
+++ b/packages/raystack/components/tour/tour.module.css
@@ -72,7 +72,7 @@
origin. `fade` mode repositions the card while it is hidden, so no glide. */
@media (prefers-reduced-motion: no-preference) {
.positioner[data-transition="move"]:has(.popup:not([data-starting-style])) {
- transition: transform 350ms var(--rs-ease-out);
+ transition: transform var(--rs-duration-slow) var(--rs-ease-out);
}
}
diff --git a/packages/raystack/components/tour/utils.ts b/packages/raystack/components/tour/utils.ts
index 6a6e42db7..bba343906 100644
--- a/packages/raystack/components/tour/utils.ts
+++ b/packages/raystack/components/tour/utils.ts
@@ -1,3 +1,6 @@
+// Must match --rs-duration-fast — the .spotlightCover fade in tour.module.css.
+export const FADE_OUT_MS = 150;
+
export interface SpotlightRect {
x: number;
y: number;
diff --git a/packages/raystack/styles/effects.css b/packages/raystack/styles/effects.css
index 648f233dc..c668305bc 100644
--- a/packages/raystack/styles/effects.css
+++ b/packages/raystack/styles/effects.css
@@ -8,13 +8,28 @@
--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;
+ --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);
/* 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 */
diff --git a/packages/raystack/styles/typography.css b/packages/raystack/styles/typography.css
index 3d9820895..eaee8a8cf 100644
--- a/packages/raystack/styles/typography.css
+++ b/packages/raystack/styles/typography.css
@@ -29,17 +29,17 @@
/* 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-mini: 0.4px;
+ --rs-letter-spacing-small: 0.3px;
--rs-letter-spacing-regular: 0.25px;
- --rs-letter-spacing-large: 0.5px;
+ --rs-letter-spacing-large: 0px;
/* Title Font Sizes */
--rs-font-size-t1: 20px;
@@ -48,16 +48,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 +103,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);
|