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
+8 -8
View File
@@ -18,19 +18,19 @@ export const themeDetails = {
}
} 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 const defaultMotionMode: MotionModeName = "default";
export const defaultMotionMode: MotionModeName = "interactive";
export const motionModeDetails = {
default: {
label: "Default",
note: "Standard Cadence UI motion for hover, press, overlays, and hierarchy"
interactive: {
label: "Interactive",
note: "Micro-interactions with hover lift, press feedback, focus transitions, and animated state changes"
},
reduced: {
label: "Reduced",
note: "Collapse durations, distances, and animated feedback"
static: {
label: "Static",
note: "Keep visual states readable while removing motion-heavy feedback and animation"
}
} as const satisfies Record<MotionModeName, { label: string; note: string }>;
+36 -21
View File
@@ -1,15 +1,15 @@
:root,
:root[data-motion="default"],
[data-motion="default"] {
:root[data-motion="interactive"],
[data-motion="interactive"] {
--dur-instant: 1ms;
--dur-fast: 120ms;
--dur-fast: 140ms;
--dur-base: 200ms;
--dur-slow: 320ms;
--dur-deliberate: 460ms;
--dur-slow: 280ms;
--dur-deliberate: 300ms;
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
--ease-emphasized: cubic-bezier(0.22, 1, 0.36, 1);
--ease-exit: cubic-bezier(0.3, 1, 0.5, 1);
--distance-xs: 4px;
--distance-sm: 8px;
@@ -21,8 +21,8 @@
--scale-pop: 1.02;
}
:root[data-motion="reduced"],
[data-motion="reduced"] {
:root[data-motion="static"],
[data-motion="static"] {
--dur-instant: 1ms;
--dur-fast: 1ms;
--dur-base: 1ms;
@@ -38,12 +38,12 @@
scroll-behavior: auto;
}
:root[data-motion="reduced"] *,
:root[data-motion="reduced"] *::before,
:root[data-motion="reduced"] *::after,
[data-motion="reduced"] *,
[data-motion="reduced"] *::before,
[data-motion="reduced"] *::after {
:root[data-motion="static"] *,
:root[data-motion="static"] *::before,
:root[data-motion="static"] *::after,
[data-motion="static"] *,
[data-motion="static"] *::before,
[data-motion="static"] *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
@@ -113,17 +113,32 @@
}
.motion-pressable {
transition-duration: var(--dur-fast);
transition-duration: var(--dur-base);
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;
}
.motion-pressable:hover {
transform: translateY(calc(var(--distance-xs) * -0.25)) scale(var(--scale-hover));
@media (hover: hover) {
.motion-pressable: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 {
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 {
+27 -5
View File
@@ -1,5 +1,5 @@
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 { buttonVariants } from "./button.variants";
@@ -24,7 +24,7 @@ function Spinner() {
exit={{ opacity: 0, rotate: 90, scale: 0.7 }}
initial={{ opacity: 0, rotate: -90, scale: 0.7 }}
transition={{
duration: 0.18,
duration: 0.16,
ease: [0.22, 1, 0.36, 1]
}}
/>
@@ -48,10 +48,32 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref
) {
const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const isDisabled = disabled || loading;
const Component = asChild ? Slot : "button";
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 ? (
<Slottable>{children}</Slottable>
) : (
@@ -62,7 +84,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
x: loading ? 1.5 : 0
}}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.18,
duration: disableMotion ? 0.01 : 0.14,
ease: [0.22, 1, 0.36, 1]
}}
>
@@ -73,7 +95,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
const sheen = !asChild ? (
<motion.span
animate={
prefersReducedMotion || isDisabled
disableMotion || isDisabled
? { x: "-120%" }
: isHovered
? { 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)]"
initial={{ x: "-120%" }}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.55,
duration: disableMotion ? 0.01 : 0.26,
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",
"[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)]",
"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))]",
@@ -7,6 +7,8 @@ export const inputVariants = cva(
"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)]",
"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)]",
"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)]",
@@ -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",
"[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)]",
"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)]",
"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))]",
@@ -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",
"[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)]",
"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))]",
@@ -15,6 +15,6 @@ export const switchVariants = 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)]",
"translate-x-0.5 transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[state=checked]:translate-x-[1.55rem]"
"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]: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)]",
"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-[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")
]);
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)]",
"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",
"[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)]",
"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)]",
"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)]",
+9 -9
View File
@@ -13,23 +13,23 @@ describe("motion contract", () => {
expect(motionModeDetails[defaultMotionMode].label).toBeTruthy();
});
it("sets default motion mode on the document root", () => {
setMotionMode("default");
it("sets interactive motion mode on the document root", () => {
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", () => {
setMotionMode("reduced");
it("sets static motion mode on the document root", () => {
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");
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-border: transparent;
--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-border-width: 2px;
@@ -83,6 +88,11 @@
--ui-input-border: var(--color-input);
--ui-input-fg: var(--color-foreground);
--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-readonly-bg: var(--color-surface);
--ui-input-backdrop-blur: 0px;
@@ -106,6 +116,8 @@
--ui-switch-thumb-radius: var(--radius-full);
--ui-switch-thumb-bg: white;
--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-block-radius: var(--radius-md);
@@ -191,6 +203,11 @@
--ui-button-destructive-fg: var(--color-destructive-foreground);
--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-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-border-width: 2px;
@@ -216,6 +233,11 @@
--ui-input-border: color-mix(in oklch, white 34%, var(--color-border));
--ui-input-fg: var(--color-foreground);
--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-readonly-bg: color-mix(in oklch, var(--color-surface) 68%, transparent);
--ui-input-backdrop-blur: 12px;
@@ -239,6 +261,8 @@
--ui-switch-thumb-radius: var(--radius-full);
--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-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-block-radius: var(--radius-lg);
@@ -319,6 +343,11 @@
--ui-button-destructive-fg: var(--color-destructive-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-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-border-width: 2px;
@@ -344,6 +373,11 @@
--ui-input-border: 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-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-readonly-bg: var(--color-surface);
--ui-input-backdrop-blur: 0px;
@@ -367,6 +401,8 @@
--ui-switch-thumb-radius: 0px;
--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-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-block-radius: 0px;