-
-
{motionMode.label}
-
- `{motionMode.value === "reduced" ? 'data-motion="reduced"' : "default"}`
- {" "}
- on the wrapper scope.
-
+ {motionPackNames.map((motionPack) => (
+
+
+
{motionPackDetails[motionPack].label}
+
+ {motionPackDetails[motionPack].note}
+
+
+ {motionAccessibilityModes.map((motionAccessibilityMode) => (
+
+
+
+
+ {motionAccessibilityMode.label}
+
+
+ {motionAccessibilityMode.value === "reduced"
+ ? '`data-motion="reduced"`'
+ : "System preference"}
+ {" "}with{" "}
+
+ {`data-motion-pack="${motionPack}"`}
+
+ .
+
+
+
+
+ {skinNames.map((skin) => (
+
+ ))}
+
-
-
- {skinNames.map((skin) => (
-
- ))}
-
+ ))}
))}
@@ -199,7 +231,8 @@ function StyleMatrixShowcase() {
Dialog still portals to the document root, so compare its real overlay and
panel treatment with the Storybook toolbar. The matrix above covers scoped
- inline regression. The control below covers the live overlay behavior.
+ inline regression across packs and reduced-motion overlay. The control below
+ covers the live overlay behavior.
diff --git a/apps/docs/src/tokens.stories.tsx b/apps/docs/src/tokens.stories.tsx
index f091526..db91981 100644
--- a/apps/docs/src/tokens.stories.tsx
+++ b/apps/docs/src/tokens.stories.tsx
@@ -1,18 +1,24 @@
import {
colorTokens,
+ defaultMotionAccessibility,
+ defaultMotionPack,
motionTokens,
+ motionAccessibilityDetails,
+ motionPackDetails,
radiusTokens,
shadowTokens,
themeDetails,
themeNames,
typographyTokens,
- type MotionModeName,
+ type MotionAccessibilityName,
+ type MotionPackName,
type ThemeName
} from "@ai-ui/tokens";
import type { Meta, StoryObj } from "@storybook/react";
type TokensOverviewProps = {
- motionMode: MotionModeName;
+ motionAccessibility: MotionAccessibilityName;
+ motionPack: MotionPackName;
theme: ThemeName;
};
@@ -119,7 +125,11 @@ function ThemeCard({ themeName }: { themeName: ThemeName }) {
);
}
-function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
+function TokensOverview({
+ motionAccessibility,
+ motionPack,
+ theme
+}: TokensOverviewProps) {
return (
@@ -157,10 +167,18 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
- Motion Mode
+ Motion Pack
- {motionMode === "system" ? "System preference" : "Reduced motion"}
+ {motionPackDetails[motionPack].label}
+
+
+
+
+ Accessibility Override
+
+
+ {motionAccessibilityDetails[motionAccessibility].label}
@@ -282,7 +300,8 @@ function TokensOverview({ motionMode, theme }: TokensOverviewProps) {
Motion tokens
Timing and motion scale now live in variables that components can consume
- directly. The toolbar can force reduced motion for preview validation.
+ directly. The toolbar now separates the active motion pack from the
+ accessibility override.
@@ -370,12 +389,19 @@ type Story = StoryObj
;
export const Overview: Story = {
args: {
- motionMode: "system",
+ motionAccessibility: defaultMotionAccessibility,
+ motionPack: defaultMotionPack,
theme: "light"
},
render: (_args, context) => (
)
diff --git a/docs/rfcs/multi-style-architecture.md b/docs/rfcs/multi-style-architecture.md
index 806516c..b237038 100644
--- a/docs/rfcs/multi-style-architecture.md
+++ b/docs/rfcs/multi-style-architecture.md
@@ -422,19 +422,27 @@ Minimum contract:
```ts
type ThemeName = "light" | "dark" | "brand" | "minimal";
type SkinName = "minimal" | "glass" | "pixel";
-type MotionName = "system" | "reduced" | "micro" | "spring";
+type MotionPackName = "calm" | "snappy" | "spring";
+type MotionAccessibilityName = "system" | "full" | "reduced";
```
Likely helpers:
- `setTheme(theme, root?)`
- `setSkin(skin, root?)`
-- `setMotionMode(mode, root?)`
+- `setMotionPack(pack, root?)`
+- `setMotionAccessibility(mode, root?)`
+- `setMotionMode(mode, root?)` as a backward-compatible alias for accessibility mode
Provider shape if needed:
```tsx
-
+
```
@@ -587,8 +595,8 @@ proven inside this repo.
recommendation is `@ai-ui/ui`.
- Should the runtime API expose a `StyleProvider`, or should root attributes remain the
only public contract at first?
-- Should motion packs beyond `system` and `reduced` ship in the first skin milestone, or
- should additional motion packs wait until after the pilot components land?
+- Should the current `calm / snappy / spring` set remain the long-term pack list, or
+ should product-specific packs be introduced later?
- Should `minimal` become the new default appearance, or should the current warm
editorial look remain the default and be renamed explicitly?
@@ -607,6 +615,8 @@ As of 2026-03-20, the project is at this point:
and `Skeleton`
- screenshot-friendly validation surface added in `Foundation/Style Matrix`
- scoped `data-motion="reduced"` now works for nested docs wrappers
+- motion now uses real packs through `data-motion-pack`
+- reduced motion remains available as a separate accessibility override layer
- shared skin-aware treatment now extends across the broader component library surface,
including controls, menus, overlays, feedback, and data-heavy patterns
- package consumers can now import a single combined stylesheet from
diff --git a/packages/tokens/src/index.ts b/packages/tokens/src/index.ts
index f18b16b..f5281f4 100644
--- a/packages/tokens/src/index.ts
+++ b/packages/tokens/src/index.ts
@@ -18,10 +18,50 @@ export const themeDetails = {
}
} as const satisfies Record;
-export const motionModeNames = ["system", "reduced"] as const;
-export type MotionModeName = (typeof motionModeNames)[number];
+export const motionPackNames = ["calm", "snappy", "spring"] as const;
+export type MotionPackName = (typeof motionPackNames)[number];
-export const defaultMotionMode: MotionModeName = "system";
+export const defaultMotionPack: MotionPackName = "calm";
+
+export const motionPackDetails = {
+ calm: {
+ label: "Calm",
+ note: "Editorial default with restrained lift and steady transitions"
+ },
+ snappy: {
+ label: "Snappy",
+ note: "Shorter durations, tighter distances, and more direct response"
+ },
+ spring: {
+ label: "Spring",
+ note: "More elastic easing, wider movement, and livelier feedback"
+ }
+} as const satisfies Record;
+
+export const motionAccessibilityNames = ["system", "full", "reduced"] as const;
+export type MotionAccessibilityName = (typeof motionAccessibilityNames)[number];
+
+export const defaultMotionAccessibility: MotionAccessibilityName = "system";
+
+export const motionAccessibilityDetails = {
+ system: {
+ label: "System",
+ note: "Follow the operating system reduced-motion preference"
+ },
+ full: {
+ label: "Full",
+ note: "Always show the selected motion pack, even if the OS prefers reduced motion"
+ },
+ reduced: {
+ label: "Reduced",
+ note: "Always collapse durations, distances, and animated feedback"
+ }
+} as const satisfies Record;
+
+export const motionModeNames = motionAccessibilityNames;
+export type MotionModeName = MotionAccessibilityName;
+
+export const defaultMotionMode: MotionModeName = defaultMotionAccessibility;
export const motionScale = {
instant: "var(--dur-instant)",
@@ -150,6 +190,16 @@ function getTargetElement(root?: HTMLElement) {
return document.documentElement;
}
+export function setMotionPack(pack: MotionPackName, root?: HTMLElement) {
+ const target = getTargetElement(root);
+
+ if (!target) {
+ return;
+ }
+
+ target.dataset.motionPack = pack;
+}
+
export function setTheme(theme: ThemeName, root?: HTMLElement) {
const target = getTargetElement(root);
@@ -160,7 +210,10 @@ export function setTheme(theme: ThemeName, root?: HTMLElement) {
target.dataset.theme = theme;
}
-export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
+export function setMotionAccessibility(
+ mode: MotionAccessibilityName,
+ root?: HTMLElement
+) {
const target = getTargetElement(root);
if (!target) {
@@ -174,3 +227,7 @@ export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
target.dataset.motion = mode;
}
+
+export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
+ setMotionAccessibility(mode, root);
+}
diff --git a/packages/tokens/src/motion.css b/packages/tokens/src/motion.css
index 59af710..b822867 100644
--- a/packages/tokens/src/motion.css
+++ b/packages/tokens/src/motion.css
@@ -1,4 +1,5 @@
-:root {
+:root,
+[data-motion-pack="calm"] {
--dur-instant: 1ms;
--dur-fast: 120ms;
--dur-base: 200ms;
@@ -19,6 +20,50 @@
--scale-pop: 1.02;
}
+:root[data-motion-pack="snappy"],
+[data-motion-pack="snappy"] {
+ --dur-instant: 1ms;
+ --dur-fast: 90ms;
+ --dur-base: 150ms;
+ --dur-slow: 220ms;
+ --dur-deliberate: 300ms;
+
+ --ease-standard: cubic-bezier(0.18, 1, 0.32, 1);
+ --ease-emphasized: cubic-bezier(0.2, 1.08, 0.28, 1);
+ --ease-exit: cubic-bezier(0.4, 0, 1, 1);
+
+ --distance-xs: 2px;
+ --distance-sm: 4px;
+ --distance-md: 10px;
+ --distance-lg: 16px;
+
+ --scale-press: 0.985;
+ --scale-hover: 1.006;
+ --scale-pop: 1.012;
+}
+
+:root[data-motion-pack="spring"],
+[data-motion-pack="spring"] {
+ --dur-instant: 1ms;
+ --dur-fast: 140ms;
+ --dur-base: 220ms;
+ --dur-slow: 360ms;
+ --dur-deliberate: 520ms;
+
+ --ease-standard: cubic-bezier(0.2, 1.08, 0.28, 1);
+ --ease-emphasized: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease-exit: cubic-bezier(0.42, 0, 1, 1);
+
+ --distance-xs: 6px;
+ --distance-sm: 12px;
+ --distance-md: 20px;
+ --distance-lg: 32px;
+
+ --scale-press: 0.96;
+ --scale-hover: 1.018;
+ --scale-pop: 1.035;
+}
+
:root[data-motion="reduced"],
[data-motion="reduced"] {
--dur-instant: 1ms;
diff --git a/packages/ui/src/lib/motion-contract.test.ts b/packages/ui/src/lib/motion-contract.test.ts
new file mode 100644
index 0000000..4159a69
--- /dev/null
+++ b/packages/ui/src/lib/motion-contract.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ defaultMotionAccessibility,
+ defaultMotionMode,
+ defaultMotionPack,
+ motionAccessibilityDetails,
+ motionAccessibilityNames,
+ motionModeNames,
+ motionPackDetails,
+ motionPackNames,
+ setMotionAccessibility,
+ setMotionMode,
+ setMotionPack
+} from "@ai-ui/tokens";
+
+describe("motion contract", () => {
+ it("exposes default values that exist in the public name sets", () => {
+ expect(motionPackNames).toContain(defaultMotionPack);
+ expect(motionAccessibilityNames).toContain(defaultMotionAccessibility);
+ expect(motionModeNames).toContain(defaultMotionMode);
+ expect(motionPackDetails[defaultMotionPack].label).toBeTruthy();
+ expect(motionAccessibilityDetails[defaultMotionAccessibility].label).toBeTruthy();
+ });
+
+ it("sets the active motion pack on the document root", () => {
+ setMotionPack("spring");
+
+ expect(document.documentElement.dataset.motionPack).toBe("spring");
+ });
+
+ it("sets reduced motion accessibility on the document root", () => {
+ setMotionAccessibility("reduced");
+
+ expect(document.documentElement.dataset.motion).toBe("reduced");
+ });
+
+ it("removes the accessibility override when system mode is restored", () => {
+ setMotionAccessibility("reduced");
+ setMotionMode("system");
+
+ expect(document.documentElement.dataset.motion).toBeUndefined();
+ });
+
+ it("supports explicit full motion override on custom roots", () => {
+ const target = document.createElement("div");
+
+ setMotionAccessibility("full", target);
+
+ expect(target.dataset.motion).toBe("full");
+ });
+});