feat(motion): add interactive micro-feedback

This commit is contained in:
2026-03-20 17:44:20 +08:00
parent 142f4a399a
commit 36822f05e0
17 changed files with 144 additions and 62 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ const preview: Preview = {
motionMode: { motionMode: {
description: "Preview motion mode", description: "Preview motion mode",
toolbar: { toolbar: {
icon: "transfer", icon: "contrast",
dynamicTitle: true, dynamicTitle: true,
items: motionModeNames.map((modeName) => ({ items: motionModeNames.map((modeName) => ({
value: modeName, value: modeName,
+1 -1
View File
@@ -187,7 +187,7 @@ function StyleContractShowcase({
"A new runtime attribute: `data-skin`", "A new runtime attribute: `data-skin`",
"Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates", "Public helpers from `@ai-ui/ui` for skin names, defaults, and root updates",
"A dedicated `@ai-ui/ui/skins.css` entrypoint imported by the docs app", "A dedicated `@ai-ui/ui/skins.css` entrypoint imported by the docs app",
"Storybook globals that apply theme, skin, and motion mode together" "Storybook globals that apply theme, skin, and interactive/static motion mode together"
].map((item) => ( ].map((item) => (
<div <div
key={item} key={item}
+1 -1
View File
@@ -213,7 +213,7 @@ function StyleMatrixShowcase() {
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]"> <p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
Dialog still portals to the document root, so compare its real overlay and Dialog still portals to the document root, so compare its real overlay and
panel treatment with the Storybook toolbar. The matrix above covers scoped panel treatment with the Storybook toolbar. The matrix above covers scoped
inline regression across default and reduced motion modes. The control below inline regression across interactive and static motion modes. The control below
covers the live overlay behavior. covers the live overlay behavior.
</p> </p>
</div> </div>
+2 -2
View File
@@ -288,8 +288,8 @@ function TokensOverview({
<h2 className="text-2xl font-semibold">Motion tokens</h2> <h2 className="text-2xl font-semibold">Motion tokens</h2>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]"> <p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
Timing and motion scale now live in variables that components can consume Timing and motion scale now live in variables that components can consume
directly. The toolbar now switches between the default interaction layer directly. The toolbar now switches between the interactive micro-feedback
and the reduced-motion fallback. layer and the static fallback.
</p> </p>
<div className="mt-6 grid gap-4 md:grid-cols-2"> <div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="space-y-3"> <div className="space-y-3">
+7 -7
View File
@@ -175,8 +175,8 @@ reduced-motion fallback:
Examples: Examples:
- `default` - `interactive`
- `reduced` - `static`
### Layout Pattern ### Layout Pattern
@@ -218,7 +218,7 @@ The existing token groups remain the baseline:
Add a new root attribute: Add a new root attribute:
```html ```html
<html data-theme="morandi" data-skin="glass" data-motion="default"> <html data-theme="morandi" data-skin="glass" data-motion="interactive">
``` ```
`data-skin` should be the runtime contract for component appearance. `data-skin` should be the runtime contract for component appearance.
@@ -422,7 +422,7 @@ Minimum contract:
```ts ```ts
type ThemeName = "morandi" | "earth" | "brand"; type ThemeName = "morandi" | "earth" | "brand";
type SkinName = "minimal" | "glass" | "pixel"; type SkinName = "minimal" | "glass" | "pixel";
type MotionModeName = "default" | "reduced"; type MotionModeName = "interactive" | "static";
``` ```
Likely helpers: Likely helpers:
@@ -437,7 +437,7 @@ Provider shape if needed:
<StyleProvider <StyleProvider
theme="morandi" theme="morandi"
skin="glass" skin="glass"
motionMode="default" motionMode="interactive"
> >
<App /> <App />
</StyleProvider> </StyleProvider>
@@ -610,8 +610,8 @@ As of 2026-03-20, the project is at this point:
- pilot recipe extraction completed for `Button`, `Card`, `Input`, `Dialog`, `Switch`, - pilot recipe extraction completed for `Button`, `Card`, `Input`, `Dialog`, `Switch`,
and `Skeleton` and `Skeleton`
- screenshot-friendly validation surface added in `Foundation/Style Matrix` - screenshot-friendly validation surface added in `Foundation/Style Matrix`
- scoped `data-motion="reduced"` now works for nested docs wrappers - scoped `data-motion="static"` now works for nested docs wrappers
- motion now uses a single `default` mode plus a `reduced` override through `data-motion` - motion now uses a single `interactive` mode plus a `static` override through `data-motion`
- shared skin-aware treatment now extends across the broader component library surface, - shared skin-aware treatment now extends across the broader component library surface,
including controls, menus, overlays, feedback, and data-heavy patterns including controls, menus, overlays, feedback, and data-heavy patterns
- package consumers can now import a single combined stylesheet from - package consumers can now import a single combined stylesheet from
+8 -8
View File
@@ -18,19 +18,19 @@ export const themeDetails = {
} }
} as const satisfies Record<ThemeName, { label: string; note: string }>; } as const satisfies Record<ThemeName, { label: string; note: string }>;
export const motionModeNames = ["default", "reduced"] as const; export const motionModeNames = ["interactive", "static"] as const;
export type MotionModeName = (typeof motionModeNames)[number]; export type MotionModeName = (typeof motionModeNames)[number];
export const defaultMotionMode: MotionModeName = "default"; export const defaultMotionMode: MotionModeName = "interactive";
export const motionModeDetails = { export const motionModeDetails = {
default: { interactive: {
label: "Default", label: "Interactive",
note: "Standard Cadence UI motion for hover, press, overlays, and hierarchy" note: "Micro-interactions with hover lift, press feedback, focus transitions, and animated state changes"
}, },
reduced: { static: {
label: "Reduced", label: "Static",
note: "Collapse durations, distances, and animated feedback" note: "Keep visual states readable while removing motion-heavy feedback and animation"
} }
} as const satisfies Record<MotionModeName, { label: string; note: string }>; } as const satisfies Record<MotionModeName, { label: string; note: string }>;
+35 -20
View File
@@ -1,15 +1,15 @@
:root, :root,
:root[data-motion="default"], :root[data-motion="interactive"],
[data-motion="default"] { [data-motion="interactive"] {
--dur-instant: 1ms; --dur-instant: 1ms;
--dur-fast: 120ms; --dur-fast: 140ms;
--dur-base: 200ms; --dur-base: 200ms;
--dur-slow: 320ms; --dur-slow: 280ms;
--dur-deliberate: 460ms; --dur-deliberate: 300ms;
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1); --ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1); --ease-emphasized: cubic-bezier(0.22, 1, 0.36, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1); --ease-exit: cubic-bezier(0.3, 1, 0.5, 1);
--distance-xs: 4px; --distance-xs: 4px;
--distance-sm: 8px; --distance-sm: 8px;
@@ -21,8 +21,8 @@
--scale-pop: 1.02; --scale-pop: 1.02;
} }
:root[data-motion="reduced"], :root[data-motion="static"],
[data-motion="reduced"] { [data-motion="static"] {
--dur-instant: 1ms; --dur-instant: 1ms;
--dur-fast: 1ms; --dur-fast: 1ms;
--dur-base: 1ms; --dur-base: 1ms;
@@ -38,12 +38,12 @@
scroll-behavior: auto; scroll-behavior: auto;
} }
:root[data-motion="reduced"] *, :root[data-motion="static"] *,
:root[data-motion="reduced"] *::before, :root[data-motion="static"] *::before,
:root[data-motion="reduced"] *::after, :root[data-motion="static"] *::after,
[data-motion="reduced"] *, [data-motion="static"] *,
[data-motion="reduced"] *::before, [data-motion="static"] *::before,
[data-motion="reduced"] *::after { [data-motion="static"] *::after {
animation-duration: 1ms !important; animation-duration: 1ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;
@@ -113,17 +113,32 @@
} }
.motion-pressable { .motion-pressable {
transition-duration: var(--dur-fast); transition-duration: var(--dur-base);
transition-property: color, background-color, border-color, box-shadow, transform; transition-property: color, background-color, border-color, box-shadow, transform;
transition-timing-function: var(--ease-standard); transition-timing-function: var(--ease-emphasized);
will-change: transform, box-shadow;
} }
@media (hover: hover) {
.motion-pressable:hover { .motion-pressable:hover {
transform: translateY(calc(var(--distance-xs) * -0.25)) scale(var(--scale-hover)); transform: translateY(var(--ui-button-hover-translate, -1px))
scale(var(--ui-button-hover-scale, 1.02));
box-shadow: var(--ui-button-hover-shadow, var(--shadow-sm));
}
}
:root[data-motion="static"] .motion-pressable:hover,
[data-motion="static"] .motion-pressable:hover,
:root[data-motion="static"] .motion-pressable:active,
[data-motion="static"] .motion-pressable:active {
box-shadow: inherit;
transform: none;
} }
.motion-pressable:active { .motion-pressable:active {
transform: scale(var(--scale-press)); transform: translateY(0) scale(var(--ui-button-press-scale, var(--scale-press)));
box-shadow: var(--ui-button-active-shadow, var(--shadow-xs));
transition-duration: var(--dur-fast);
} }
.motion-enter-fade { .motion-enter-fade {
+27 -5
View File
@@ -1,5 +1,5 @@
import { Slot, Slottable } from "@radix-ui/react-slot"; import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef, useState } from "react"; import { forwardRef, useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { buttonVariants } from "./button.variants"; import { buttonVariants } from "./button.variants";
@@ -24,7 +24,7 @@ function Spinner() {
exit={{ opacity: 0, rotate: 90, scale: 0.7 }} exit={{ opacity: 0, rotate: 90, scale: 0.7 }}
initial={{ opacity: 0, rotate: -90, scale: 0.7 }} initial={{ opacity: 0, rotate: -90, scale: 0.7 }}
transition={{ transition={{
duration: 0.18, duration: 0.16,
ease: [0.22, 1, 0.36, 1] ease: [0.22, 1, 0.36, 1]
}} }}
/> />
@@ -48,10 +48,32 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref ref
) { ) {
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isDisabled = disabled || loading; const isDisabled = disabled || loading;
const Component = asChild ? Slot : "button"; const Component = asChild ? Slot : "button";
const baseClassName = cn(buttonVariants({ loading, size, variant }), className); const baseClassName = cn(buttonVariants({ loading, size, variant }), className);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setIsStaticMotion(document.documentElement.dataset.motion === "static");
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
return () => observer.disconnect();
}, []);
const disableMotion = prefersReducedMotion || isStaticMotion;
const label = asChild ? ( const label = asChild ? (
<Slottable>{children}</Slottable> <Slottable>{children}</Slottable>
) : ( ) : (
@@ -62,7 +84,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
x: loading ? 1.5 : 0 x: loading ? 1.5 : 0
}} }}
transition={{ transition={{
duration: prefersReducedMotion ? 0.01 : 0.18, duration: disableMotion ? 0.01 : 0.14,
ease: [0.22, 1, 0.36, 1] ease: [0.22, 1, 0.36, 1]
}} }}
> >
@@ -73,7 +95,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
const sheen = !asChild ? ( const sheen = !asChild ? (
<motion.span <motion.span
animate={ animate={
prefersReducedMotion || isDisabled disableMotion || isDisabled
? { x: "-120%" } ? { x: "-120%" }
: isHovered : isHovered
? { x: "115%" } ? { x: "115%" }
@@ -83,7 +105,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
className="pointer-events-none absolute inset-y-0 left-0 w-1/2 rounded-[inherit] bg-[var(--ui-button-sheen-gradient)] opacity-[var(--ui-button-sheen-opacity)] [mix-blend-mode:var(--ui-button-sheen-mix)]" className="pointer-events-none absolute inset-y-0 left-0 w-1/2 rounded-[inherit] bg-[var(--ui-button-sheen-gradient)] opacity-[var(--ui-button-sheen-opacity)] [mix-blend-mode:var(--ui-button-sheen-mix)]"
initial={{ x: "-120%" }} initial={{ x: "-120%" }}
transition={{ transition={{
duration: prefersReducedMotion ? 0.01 : 0.55, duration: disableMotion ? 0.01 : 0.26,
ease: [0.16, 1, 0.3, 1] ease: [0.16, 1, 0.3, 1]
}} }}
/> />
@@ -5,6 +5,8 @@ export const comboboxTriggerVariants = cva(
[ [
"inline-flex h-11 w-full items-center justify-between gap-3 rounded-[var(--ui-input-radius)] border bg-[var(--ui-input-bg)] px-4 text-left text-sm text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none", "inline-flex h-11 w-full items-center justify-between gap-3 rounded-[var(--ui-input-radius)] border bg-[var(--ui-input-bg)] px-4 text-left text-sm text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none",
"[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]", "[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]",
"transition-[border-color,box-shadow,background-color,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-visible:-translate-y-[var(--ui-input-focus-lift)] focus-visible:border-[var(--ui-input-focus-border)] focus-visible:shadow-[var(--ui-input-focus-shadow)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[placeholder]:text-[var(--color-muted-foreground)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100", "data-[placeholder]:text-[var(--color-muted-foreground)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]", "aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
@@ -7,6 +7,8 @@ export const inputVariants = cva(
"text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none", "text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none",
"[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]", "[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]",
"placeholder:text-[var(--color-muted-foreground)]", "placeholder:text-[var(--color-muted-foreground)]",
"transition-[border-color,box-shadow,background-color,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-visible:-translate-y-[var(--ui-input-focus-lift)] focus-visible:border-[var(--ui-input-focus-border)] focus-visible:shadow-[var(--ui-input-focus-shadow)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:cursor-not-allowed disabled:bg-[var(--ui-input-disabled-bg)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100", "disabled:cursor-not-allowed disabled:bg-[var(--ui-input-disabled-bg)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:bg-[var(--ui-input-readonly-bg)] read-only:text-[var(--color-muted-foreground)]", "read-only:bg-[var(--ui-input-readonly-bg)] read-only:text-[var(--color-muted-foreground)]",
@@ -6,6 +6,8 @@ export const selectTriggerVariants = cva(
"inline-flex h-11 w-full items-center justify-between gap-3 rounded-[var(--ui-input-radius)] border bg-[var(--ui-input-bg)] px-4 text-left text-sm text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none", "inline-flex h-11 w-full items-center justify-between gap-3 rounded-[var(--ui-input-radius)] border bg-[var(--ui-input-bg)] px-4 text-left text-sm text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none",
"[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]", "[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]",
"placeholder:text-[var(--color-muted-foreground)]", "placeholder:text-[var(--color-muted-foreground)]",
"transition-[border-color,box-shadow,background-color,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-visible:-translate-y-[var(--ui-input-focus-lift)] focus-visible:border-[var(--ui-input-focus-border)] focus-visible:shadow-[var(--ui-input-focus-shadow)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[placeholder]:text-[var(--color-muted-foreground)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100", "data-[placeholder]:text-[var(--color-muted-foreground)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--ui-input-disabled-bg)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]", "aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
@@ -5,7 +5,7 @@ export const switchVariants = cva(
[ [
"inline-flex h-7 w-12 shrink-0 items-center rounded-[var(--ui-switch-track-radius)] border bg-[var(--ui-switch-track-bg)] shadow-[var(--ui-switch-track-shadow)] outline-none", "inline-flex h-7 w-12 shrink-0 items-center rounded-[var(--ui-switch-track-radius)] border bg-[var(--ui-switch-track-bg)] shadow-[var(--ui-switch-track-shadow)] outline-none",
"[border-width:var(--ui-switch-track-border-width)] border-[var(--ui-switch-track-border)]", "[border-width:var(--ui-switch-track-border-width)] border-[var(--ui-switch-track-border)]",
"transition-[background-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-standard)]", "transition-[background-color,border-color,box-shadow] duration-[var(--ui-switch-transition-duration,var(--dur-base))] ease-[var(--ease-emphasized)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[state=checked]:bg-[var(--ui-switch-track-checked-bg)] data-[state=checked]:border-[var(--ui-switch-track-checked-border)] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45", "data-[state=checked]:bg-[var(--ui-switch-track-checked-bg)] data-[state=checked]:border-[var(--ui-switch-track-checked-border)] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]", "aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
@@ -15,6 +15,6 @@ export const switchVariants = cva(
export const switchThumbVariants = cva([ export const switchThumbVariants = cva([
"pointer-events-none block size-5 rounded-[var(--ui-switch-thumb-radius)] bg-[var(--ui-switch-thumb-bg)] shadow-[var(--ui-switch-thumb-shadow)]", "pointer-events-none block size-5 rounded-[var(--ui-switch-thumb-radius)] bg-[var(--ui-switch-thumb-bg)] shadow-[var(--ui-switch-thumb-shadow)]",
"translate-x-0.5 transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]", "translate-x-0.5 will-change-transform transition-[transform,box-shadow,background-color] duration-[var(--ui-switch-transition-duration,var(--dur-base))] ease-[var(--ease-emphasized)]",
"data-[state=checked]:translate-x-[1.55rem]" "data-[state=checked]:translate-x-[1.55rem] data-[state=checked]:shadow-[var(--ui-switch-thumb-checked-shadow,var(--ui-switch-thumb-shadow))]"
]); ]);
+3 -2
View File
@@ -10,11 +10,12 @@ export const tabsTriggerVariants = cva([
"text-[var(--color-muted-foreground)] transition-[color,background-color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]", "text-[var(--color-muted-foreground)] transition-[color,background-color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45", "data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
"data-[state=active]:bg-[var(--ui-panel-bg)] data-[state=active]:text-[var(--color-foreground)] data-[state=active]:shadow-[var(--ui-control-shadow)]", "hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--ui-control-bg)_76%,white_24%)] hover:text-[var(--color-foreground)]",
"data-[state=active]:-translate-y-px data-[state=active]:bg-[var(--ui-panel-bg)] data-[state=active]:text-[var(--color-foreground)] data-[state=active]:shadow-[var(--ui-control-shadow)]",
getMotionRecipeClassNames("ring") getMotionRecipeClassNames("ring")
]); ]);
export const tabsContentVariants = cva([ export const tabsContentVariants = cva([
"mt-4 rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] p-6 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] outline-none [border-width:var(--ui-card-border-width)]", "mt-4 rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] p-6 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] outline-none [border-width:var(--ui-card-border-width)]",
"data-[state=active]:motion-enter-rise" "data-[state=active]:motion-enter-fade data-[state=active]:motion-enter-rise"
]); ]);
@@ -7,6 +7,8 @@ export const textareaVariants = cva(
"text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none", "text-[var(--ui-input-fg)] shadow-[var(--ui-input-shadow)] outline-none",
"[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]", "[border-width:var(--ui-input-border-width)] border-[var(--ui-input-border)] backdrop-blur-[var(--ui-input-backdrop-blur)]",
"placeholder:text-[var(--color-muted-foreground)]", "placeholder:text-[var(--color-muted-foreground)]",
"transition-[border-color,box-shadow,background-color,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-visible:-translate-y-[var(--ui-input-focus-lift)] focus-visible:border-[var(--ui-input-focus-border)] focus-visible:shadow-[var(--ui-input-focus-shadow)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]", "focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:cursor-not-allowed disabled:bg-[var(--ui-input-disabled-bg)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100", "disabled:cursor-not-allowed disabled:bg-[var(--ui-input-disabled-bg)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:bg-[var(--ui-input-readonly-bg)] read-only:text-[var(--color-muted-foreground)]", "read-only:bg-[var(--ui-input-readonly-bg)] read-only:text-[var(--color-muted-foreground)]",
+9 -9
View File
@@ -13,23 +13,23 @@ describe("motion contract", () => {
expect(motionModeDetails[defaultMotionMode].label).toBeTruthy(); expect(motionModeDetails[defaultMotionMode].label).toBeTruthy();
}); });
it("sets default motion mode on the document root", () => { it("sets interactive motion mode on the document root", () => {
setMotionMode("default"); setMotionMode("interactive");
expect(document.documentElement.dataset.motion).toBe("default"); expect(document.documentElement.dataset.motion).toBe("interactive");
}); });
it("sets reduced motion mode on the document root", () => { it("sets static motion mode on the document root", () => {
setMotionMode("reduced"); setMotionMode("static");
expect(document.documentElement.dataset.motion).toBe("reduced"); expect(document.documentElement.dataset.motion).toBe("static");
}); });
it("supports explicit reduced mode on custom roots", () => { it("supports explicit static mode on custom roots", () => {
const target = document.createElement("div"); const target = document.createElement("div");
setMotionMode("reduced", target); setMotionMode("static", target);
expect(target.dataset.motion).toBe("reduced"); expect(target.dataset.motion).toBe("static");
}); });
}); });
+36
View File
@@ -58,6 +58,11 @@
--ui-button-destructive-fg: var(--color-destructive-foreground); --ui-button-destructive-fg: var(--color-destructive-foreground);
--ui-button-destructive-border: transparent; --ui-button-destructive-border: transparent;
--ui-button-destructive-shadow: var(--shadow-xs); --ui-button-destructive-shadow: var(--shadow-xs);
--ui-button-hover-scale: 1.02;
--ui-button-press-scale: 0.98;
--ui-button-hover-translate: -1px;
--ui-button-hover-shadow: var(--shadow-sm);
--ui-button-active-shadow: var(--shadow-xs);
--ui-spinner-radius: var(--radius-full); --ui-spinner-radius: var(--radius-full);
--ui-spinner-border-width: 2px; --ui-spinner-border-width: 2px;
@@ -83,6 +88,11 @@
--ui-input-border: var(--color-input); --ui-input-border: var(--color-input);
--ui-input-fg: var(--color-foreground); --ui-input-fg: var(--color-foreground);
--ui-input-shadow: var(--shadow-xs); --ui-input-shadow: var(--shadow-xs);
--ui-input-focus-border: color-mix(in oklch, var(--color-primary) 32%, var(--color-input));
--ui-input-focus-shadow:
0 0 0 1px color-mix(in oklch, var(--color-primary) 18%, transparent),
var(--shadow-sm);
--ui-input-focus-lift: -1px;
--ui-input-disabled-bg: var(--color-surface); --ui-input-disabled-bg: var(--color-surface);
--ui-input-readonly-bg: var(--color-surface); --ui-input-readonly-bg: var(--color-surface);
--ui-input-backdrop-blur: 0px; --ui-input-backdrop-blur: 0px;
@@ -106,6 +116,8 @@
--ui-switch-thumb-radius: var(--radius-full); --ui-switch-thumb-radius: var(--radius-full);
--ui-switch-thumb-bg: white; --ui-switch-thumb-bg: white;
--ui-switch-thumb-shadow: var(--shadow-xs); --ui-switch-thumb-shadow: var(--shadow-xs);
--ui-switch-thumb-checked-shadow: var(--shadow-sm);
--ui-switch-transition-duration: var(--dur-base);
--ui-skeleton-radius: var(--radius-sm); --ui-skeleton-radius: var(--radius-sm);
--ui-skeleton-block-radius: var(--radius-md); --ui-skeleton-block-radius: var(--radius-md);
@@ -191,6 +203,11 @@
--ui-button-destructive-fg: var(--color-destructive-foreground); --ui-button-destructive-fg: var(--color-destructive-foreground);
--ui-button-destructive-border: color-mix(in oklch, white 28%, var(--color-destructive)); --ui-button-destructive-border: color-mix(in oklch, white 28%, var(--color-destructive));
--ui-button-destructive-shadow: 0 16px 34px oklch(0.32 0.07 18 / 0.18); --ui-button-destructive-shadow: 0 16px 34px oklch(0.32 0.07 18 / 0.18);
--ui-button-hover-scale: 1.02;
--ui-button-press-scale: 0.985;
--ui-button-hover-translate: -2px;
--ui-button-hover-shadow: 0 20px 42px oklch(0.2 0.04 250 / 0.18);
--ui-button-active-shadow: 0 10px 24px oklch(0.2 0.04 250 / 0.14);
--ui-spinner-radius: var(--radius-full); --ui-spinner-radius: var(--radius-full);
--ui-spinner-border-width: 2px; --ui-spinner-border-width: 2px;
@@ -216,6 +233,11 @@
--ui-input-border: color-mix(in oklch, white 34%, var(--color-border)); --ui-input-border: color-mix(in oklch, white 34%, var(--color-border));
--ui-input-fg: var(--color-foreground); --ui-input-fg: var(--color-foreground);
--ui-input-shadow: 0 14px 34px oklch(0.2 0.03 255 / 0.12); --ui-input-shadow: 0 14px 34px oklch(0.2 0.03 255 / 0.12);
--ui-input-focus-border: color-mix(in oklch, white 44%, var(--color-primary));
--ui-input-focus-shadow:
0 0 0 1px color-mix(in oklch, white 22%, var(--color-primary)),
0 18px 40px oklch(0.2 0.03 255 / 0.18);
--ui-input-focus-lift: -1px;
--ui-input-disabled-bg: color-mix(in oklch, var(--color-surface) 72%, transparent); --ui-input-disabled-bg: color-mix(in oklch, var(--color-surface) 72%, transparent);
--ui-input-readonly-bg: color-mix(in oklch, var(--color-surface) 68%, transparent); --ui-input-readonly-bg: color-mix(in oklch, var(--color-surface) 68%, transparent);
--ui-input-backdrop-blur: 12px; --ui-input-backdrop-blur: 12px;
@@ -239,6 +261,8 @@
--ui-switch-thumb-radius: var(--radius-full); --ui-switch-thumb-radius: var(--radius-full);
--ui-switch-thumb-bg: color-mix(in oklch, white 84%, var(--color-card)); --ui-switch-thumb-bg: color-mix(in oklch, white 84%, var(--color-card));
--ui-switch-thumb-shadow: 0 8px 18px oklch(0.16 0.02 255 / 0.22); --ui-switch-thumb-shadow: 0 8px 18px oklch(0.16 0.02 255 / 0.22);
--ui-switch-thumb-checked-shadow: 0 12px 24px oklch(0.18 0.03 255 / 0.28);
--ui-switch-transition-duration: var(--dur-base);
--ui-skeleton-radius: var(--radius-md); --ui-skeleton-radius: var(--radius-md);
--ui-skeleton-block-radius: var(--radius-lg); --ui-skeleton-block-radius: var(--radius-lg);
@@ -319,6 +343,11 @@
--ui-button-destructive-fg: var(--color-destructive-foreground); --ui-button-destructive-fg: var(--color-destructive-foreground);
--ui-button-destructive-border: var(--color-foreground); --ui-button-destructive-border: var(--color-foreground);
--ui-button-destructive-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 34%, transparent); --ui-button-destructive-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 34%, transparent);
--ui-button-hover-scale: 1;
--ui-button-press-scale: 0.98;
--ui-button-hover-translate: -1px;
--ui-button-hover-shadow: 5px 5px 0 color-mix(in oklch, var(--color-foreground) 36%, transparent);
--ui-button-active-shadow: 1px 1px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent);
--ui-spinner-radius: 0px; --ui-spinner-radius: 0px;
--ui-spinner-border-width: 2px; --ui-spinner-border-width: 2px;
@@ -344,6 +373,11 @@
--ui-input-border: var(--color-foreground); --ui-input-border: var(--color-foreground);
--ui-input-fg: var(--color-foreground); --ui-input-fg: var(--color-foreground);
--ui-input-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent); --ui-input-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent);
--ui-input-focus-border: var(--color-primary);
--ui-input-focus-shadow:
0 0 0 2px color-mix(in oklch, var(--color-primary) 42%, transparent),
4px 4px 0 color-mix(in oklch, var(--color-foreground) 32%, transparent);
--ui-input-focus-lift: 0px;
--ui-input-disabled-bg: var(--color-surface); --ui-input-disabled-bg: var(--color-surface);
--ui-input-readonly-bg: var(--color-surface); --ui-input-readonly-bg: var(--color-surface);
--ui-input-backdrop-blur: 0px; --ui-input-backdrop-blur: 0px;
@@ -367,6 +401,8 @@
--ui-switch-thumb-radius: 0px; --ui-switch-thumb-radius: 0px;
--ui-switch-thumb-bg: var(--color-background); --ui-switch-thumb-bg: var(--color-background);
--ui-switch-thumb-shadow: 2px 2px 0 color-mix(in oklch, var(--color-foreground) 24%, transparent); --ui-switch-thumb-shadow: 2px 2px 0 color-mix(in oklch, var(--color-foreground) 24%, transparent);
--ui-switch-thumb-checked-shadow: 3px 3px 0 color-mix(in oklch, var(--color-foreground) 28%, transparent);
--ui-switch-transition-duration: var(--dur-fast);
--ui-skeleton-radius: 0px; --ui-skeleton-radius: 0px;
--ui-skeleton-block-radius: 0px; --ui-skeleton-block-radius: 0px;
+1 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("storybook button, select, and reduced-motion form stories stay interactive", async ({ test("storybook button, select, and static-motion form stories stay interactive", async ({
page page
}) => { }) => {
await page.goto("/"); await page.goto("/");