diff --git a/.claude/plugins/n8n/skills/design-system/SKILL.md b/.claude/plugins/n8n/skills/design-system/SKILL.md index d07a1472338..eecdfa4ae2b 100644 --- a/.claude/plugins/n8n/skills/design-system/SKILL.md +++ b/.claude/plugins/n8n/skills/design-system/SKILL.md @@ -19,12 +19,15 @@ Reference these guidelines when: - Follow guidelines in `packages/frontend/@n8n/design-system/src/styleguide/*.mdx` - ALWAYS use CSS variables for styles from `packages/frontend/@n8n/design-system/src/css/_tokens.scss` or `packages/frontend/@n8n/design-system/src/css/_primtivies.scss`. Use hard-coded values only when no suitable tokens. - ALWAYS prefer using existing components from `packages/frontend/@n8n/design-system/src/components`. Prefer components that aren't marked `@deprecated`. -- When reviewing UI changes or adding new components, follow `rules/web-interface-guidelines.md` - Use `light-dark()` when alternating colors for ligh/dark mode +- When working with animations or transitions, ALWAYS prefer using mixins from `packages/frontend/@n8n/design-system/src/css/mixins/motion.scss` +- When reviewing animations, follow the guides in `rules/web-animation-guidelines.md` +- When reviewing UI changes or adding new components, follow `rules/web-interface-guidelines.md` ## Examples - "Add a modal dialog for confirming workflow deletion" → Use `N8nDialog` - "Add a dropdown to select workflow status" → Use `N8nDropdown` or `N8nSelect` - "Add button with + icon to add new tiem" → Wrap `N8nButton` with `iconOnly` prop with `N8nTooltip` and wrap in `N8nTooltip`. Use `N8nIcon` and proper aria-label. - "Add a destructive action button" → use `N8nButton` with `variant="destructive"` -- Make background color white/black → Use `var(--background--surface)` for white on light mode and "black" on dark mode +- "Make background color white/black" → Use `var(--background--surface)` for white on light mode and "black" on dark mode +- "Animate the title in gracefully" -> Use `fade-in-up` mixin from `motion.scss` with `var(--duration--base)` diff --git a/.claude/plugins/n8n/skills/design-system/rules/web-animation-guidelines.md b/.claude/plugins/n8n/skills/design-system/rules/web-animation-guidelines.md new file mode 100644 index 00000000000..0b377bd7dfe --- /dev/null +++ b/.claude/plugins/n8n/skills/design-system/rules/web-animation-guidelines.md @@ -0,0 +1,93 @@ +# Web Motion Guidelines +Design and implement web animations that feel natural and purposeful + +## Timing and Duration + +## Duration Guidelines + +| Element Type | Duration | +| --------------------------------- | --------- | +| Micro-interactions | 100-150ms | +| Standard UI (tooltips, dropdowns) | 150-250ms | +| Modals, drawers | 200-300ms | + +**Rules:** + +- UI animations should stay under 300ms +- Larger elements animate slower than smaller ones +- Exit animations can be ~20% faster than entrance +- Match duration to distance - longer travel = longer duration + +### The Frequency + +Determine how often users will see the animation: + +- **100+ times/day** → No animation (or drastically reduced) +- **Occasional use** → Standard animation +- **Rare/first-time** → Can be more special + +**Example:** Raycast never animates because users open it hundreds of times a day. + +## When to Animate + +**Do animate:** + +- Enter/exit transitions for spatial consistency +- State changes that benefit from visual continuity +- Responses to user actions (feedback) +- Rarely-used interactions where delight adds value + +**Don't animate:** + +- Keyboard-initiated actions +- Hover effects on frequently-used elements +- Anything users interact with 100+ times daily +- When speed matters more than smoothness + +## Performance + +Prefer animating `transform` and `opacity`. These skip layout and paint stages, running entirely on the GPU. + +**Avoid animating:** + +- `padding`, `margin`, `height`, `width` (trigger layout) +- `blur` filters above 20px (expensive, especially Safari) +- CSS variables in deep component trees + +### Optimization Techniques + +```css +/* Force GPU acceleration */ +.animated-element { + will-change: transform; +} +``` + +## Practical Tips + +Quick reference for common scenarios. See [PRACTICAL-TIPS.md](PRACTICAL-TIPS.md) for detailed implementations. + +| Scenario | Solution | +| ------------------------------- | ----------------------------------------------- | +| Make buttons feel responsive | Add `transform: scale(0.97)` on `:active` | +| Element appears from nowhere | Start from `scale(0.95)`, not `scale(0)` | +| Shaky/jittery animations | Add `will-change: transform` | +| Hover causes flicker | Animate child element, not parent | +| Popover scales from wrong point | Set `transform-origin` to trigger location | +| Sequential tooltips feel slow | Skip delay/animation after first tooltip | +| Small buttons hard to tap | Use 44px minimum hit area (pseudo-element) | +| Something still feels off | Add subtle blur (under 20px) to mask it | +| Hover triggers on mobile | Use `@media (hover: hover) and (pointer: fine)` | + +## Easing Decision Flowchart + +Is the element entering or exiting the viewport? +├── Yes → ease-out +└── No +├── Is it moving/morphing on screen? +│ └── Yes → ease-in-out +└── Is it a hover change? +├── Yes → ease +└── Is it constant motion? +├── Yes → linear +└── Default → ease-out diff --git a/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts b/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts index 18bed5c870e..ab21b124640 100644 --- a/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts +++ b/packages/@n8n/stylelint-config/src/rules/css-var-naming.ts @@ -16,6 +16,7 @@ const meta = { // NOTE: color--text, color--background, color--foreground use double dashes // to separate "color" from the subtype (text/background/foreground) const PROPERTY_VOCABULARY = new Set([ + 'animation', 'color', 'color--text', 'color--background', diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ThinkingMessage.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ThinkingMessage.vue index d0efbf67e53..45bbb847638 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ThinkingMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ThinkingMessage.vue @@ -78,7 +78,7 @@ function getIconForStatus(status: ChatUI.ThinkingItem['status']) { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue index 36e18b6d40c..ccaeab6e828 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue @@ -127,6 +127,7 @@ const handleClick = (event: MouseEvent) => { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCollapsiblePanel/CollapsiblePanel.vue b/packages/frontend/@n8n/design-system/src/components/N8nCollapsiblePanel/CollapsiblePanel.vue index e68a0cf4b5b..8638517772e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCollapsiblePanel/CollapsiblePanel.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCollapsiblePanel/CollapsiblePanel.vue @@ -80,6 +80,8 @@ const isOpen = computed({ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDropdown/Dropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nDropdown/Dropdown.vue index 1abaa00f702..440d86ea1db 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDropdown/Dropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDropdown/Dropdown.vue @@ -129,6 +129,8 @@ defineExpose({ diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.vue b/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.vue index cca3e49522c..f70c62270ac 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/Icon.vue @@ -125,6 +125,8 @@ const resolvedComponent = computed( diff --git a/packages/frontend/@n8n/design-system/src/components/N8nPulse/Pulse.vue b/packages/frontend/@n8n/design-system/src/components/N8nPulse/Pulse.vue index 5641ce47cef..3470c3ab112 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nPulse/Pulse.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nPulse/Pulse.vue @@ -13,19 +13,7 @@ defineOptions({ name: 'N8nPulse' }); diff --git a/packages/frontend/@n8n/design-system/src/css/_primitives.scss b/packages/frontend/@n8n/design-system/src/css/_primitives.scss index cffbd6b27c0..0991467970c 100644 --- a/packages/frontend/@n8n/design-system/src/css/_primitives.scss +++ b/packages/frontend/@n8n/design-system/src/css/_primitives.scss @@ -291,6 +291,9 @@ --duration--snappy: 200ms; --duration--base: 400ms; + --duration--slow: 1000ms; + --duration--slowest: 1500ms; + --easing--ease-out: cubic-bezier(0.215, 0.61, 0.355, 1); --easing--ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19); --easing--ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1); diff --git a/packages/frontend/@n8n/design-system/src/css/mixins/animations.scss b/packages/frontend/@n8n/design-system/src/css/mixins/animations.scss deleted file mode 100644 index 11965a03c64..00000000000 --- a/packages/frontend/@n8n/design-system/src/css/mixins/animations.scss +++ /dev/null @@ -1,16 +0,0 @@ -@mixin shimmer { - background: linear-gradient(135deg, #fff, #5e5e5e, #fff); - background-clip: text; - color: transparent; - background-size: 200% 100%; - animation: shimmer 3.33s linear infinite; -} - -@keyframes shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } -} diff --git a/packages/frontend/@n8n/design-system/src/css/mixins/motion.scss b/packages/frontend/@n8n/design-system/src/css/mixins/motion.scss new file mode 100644 index 00000000000..ba6d6e80b60 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/css/mixins/motion.scss @@ -0,0 +1,490 @@ +// Shimmer - animated gradient for loading/skeleton states +@mixin shimmer { + background: linear-gradient( + 135deg, + var(--animation--shimmer--background, var(--background--surface)), + var(--animation--shimmer--foreground, var(--text-color--subtle)), + var(--animation--shimmer--background, var(--background--surface)) + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer var(--animation--shimmer--duration, var(--duration--slow)) infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +// Spin - continuous 360° rotation for loading indicators +@mixin spin { + animation: spin var(--animation--spin--duration, var(--duration--base)) linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +// Skeleton pulse - opacity animation for skeleton loading states +@mixin skeleton-pulse { + animation: skeleton-pulse var(--animation--skeleton-pulse--duration, var(--duration--slowest)) + ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@mixin opacity-pulse { + animation: opacityPulse var(--animation--opacity-pulse--duration, var(--duration--slow)) + var(--animation--opacity-pulse--easing, ease-in-out) infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@mixin popover-in { + animation: popoverIn var(--animation--popover-in--duration, var(--duration--snappy)) + var(--animation--popover-in--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@mixin collapsible-slide-down { + animation: collapsibleSlideDown + var(--animation--collapsible-slide--duration, var(--duration--snappy)) + var(--animation--collapsible-slide--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@mixin collapsible-slide-up { + animation: collapsibleSlideUp + var(--animation--collapsible-slide--duration, var(--duration--snappy)) + var(--animation--collapsible-slide--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +// Fade in - simple opacity fade for entrance animations +@mixin fade-in { + animation: fadeIn var(--animation--fade-in--duration, var(--duration--snappy)) + var(--easing--ease-out); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +// Keyframes + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes skeleton-pulse { + 0%, + 100% { + opacity: var(--animation--skeleton-pulse--opacity-start, 1); + } + 50% { + opacity: var(--animation--skeleton-pulse--opacity-end, 0.4); + } +} + +@keyframes opacityPulse { + 0%, + 100% { + opacity: var(--animation--opacity-pulse--opacity-start, 1); + } + 50% { + opacity: var(--animation--opacity-pulse--opacity-end, 0.4); + } +} + +@keyframes popoverIn { + from { + opacity: 0; + transform: translate( + var(--animation--popover-in--translate-x, 0), + var(--animation--popover-in--translate-y, 0) + ) + scale(var(--animation--popover-in--scale, 0.96)); + } + to { + opacity: 1; + transform: translate(0, 0) scale(1); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(var(--animation--fade-in--translate, 8px)); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes collapsibleSlideDown { + from { + height: 0; + } + to { + height: var(--reka-collapsible-content-height); + } +} + +@keyframes collapsibleSlideUp { + from { + height: var(--reka-collapsible-content-height); + } + to { + height: 0; + } +} + +// Pulse glow - expanding glow effect for loading states (used by N8nPulse) +@mixin pulse-glow { + animation: pulseGlow var(--animation--pulse-glow--duration, 6s) infinite + var(--animation--pulse-glow--easing, cubic-bezier(0.33, 1, 0.68, 1)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@mixin pulse-glow-delayed { + animation: pulseGlowDelayed var(--animation--pulse-glow--duration, 6s) infinite + var(--animation--pulse-glow--easing, cubic-bezier(0.33, 1, 0.68, 1)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes pulseGlow { + 0% { + box-shadow: 0 0 0 0 + hsla(var(--color--primary--h), var(--color--primary--s), var(--color--primary--l), 0.4); + } + 58.33% { + box-shadow: 0 0 0 60px + hsla(var(--color--primary--h), var(--color--primary--s), var(--color--primary--l), 0); + } + 66.6% { + box-shadow: 0 0 0 66px transparent; + } + 66.7% { + box-shadow: 0 0 0 0 transparent; + } +} + +@keyframes pulseGlowDelayed { + 0%, + 16.66% { + box-shadow: 0 0 0 0 + hsla(var(--color--primary--h), var(--color--primary--s), var(--color--primary--l), 0.4); + } + 50% { + box-shadow: 0 0 0 60px + hsla(var(--color--primary--h), var(--color--primary--s), var(--color--primary--l), 0); + } + 83.3% { + box-shadow: 0 0 0 66px transparent; + } + 83.4% { + box-shadow: 0 0 0 0 transparent; + } +} + +// Fade animation for simple opacity transitions +@mixin fade { + animation: fade var(--animation--fade--duration, var(--duration--snappy)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fade { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@mixin fade-in-up { + animation: fadeInUp var(--animation--fade-in-up--duration, var(--duration--snappy)) + var(--animation--fade-in-up--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(var(--animation--fade-in-up--translate, var(--spacing--4xs))); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@mixin fade-in-down { + animation: fadeInDown var(--animation--fade-in-down--duration, var(--duration--snappy)) + var(--animation--fade-in-down--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY( + calc(-1 * var(--animation--fade-in-down--translate, var(--spacing--4xs))) + ); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@mixin fade-in-left { + animation: fadeInLeft var(--animation--fade-in-left--duration, var(--duration--snappy)) + var(--animation--fade-in-left--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeInLeft { + from { + opacity: 0; + transform: translateX( + calc(-1 * var(--animation--fade-in-left--translate, var(--spacing--4xs))) + ); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@mixin fade-in-right { + animation: fadeInRight var(--animation--fade-in-right--duration, var(--duration--snappy)) + var(--animation--fade-in-right--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeInRight { + from { + opacity: 0; + transform: translateX(var(--animation--fade-in-right--translate, var(--spacing--4xs))); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@mixin fade-out { + animation: fadeOut var(--animation--fade-out--duration, var(--duration--snappy)) + var(--animation--fade-out--easing, var(--easing--ease-in)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@mixin fade-out-down { + animation: fadeOutDown var(--animation--fade-out-down--duration, var(--duration--snappy)) + var(--animation--fade-out-down--easing, var(--easing--ease-in)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeOutDown { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(var(--animation--fade-out-down--translate, var(--spacing--4xs))); + } +} + +@mixin fade-out-up { + animation: fadeOutUp var(--animation--fade-out-up--duration, var(--duration--snappy)) + var(--animation--fade-out-up--easing, var(--easing--ease-in)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeOutUp { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(calc(-1 * var(--animation--fade-out-up--translate, var(--spacing--4xs)))); + } +} + +@mixin fade-out-left { + animation: fadeOutLeft var(--animation--fade-out-left--duration, var(--duration--snappy)) + var(--animation--fade-out-left--easing, var(--easing--ease-in)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeOutLeft { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX( + calc(-1 * var(--animation--fade-out-left--translate, var(--spacing--4xs))) + ); + } +} + +@mixin fade-out-right { + animation: fadeOutRight var(--animation--fade-out-right--duration, var(--duration--snappy)) + var(--animation--fade-out-right--easing, var(--easing--ease-in)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes fadeOutRight { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(var(--animation--fade-out-right--translate, var(--spacing--4xs))); + } +} + +@mixin blink-background { + animation: blinkBackground var(--animation--blink-background--duration, var(--duration--slow)) + step-end infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes blinkBackground { + from, + to { + background-color: var(--animation--blink-background--color-start, transparent); + } + 50% { + background-color: var( + --animation--blink-background--color-end, + var(--color--foreground--shade-2) + ); + } +} + +@mixin typing-blink { + animation: typingBlink var(--animation--typing-blink--duration, 1.2s) infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes typingBlink { + 0%, + 80%, + 100% { + opacity: var(--animation--typing-blink--opacity-start, 0.35); + transform: translateY(0); + } + 40% { + opacity: var(--animation--typing-blink--opacity-end, 1); + transform: translateY(var(--animation--typing-blink--translate, -2px)); + } +} + +@mixin width-transition { + transition: width var(--animation--width-transition--duration, var(--duration--snappy)) + var(--animation--width-transition--easing, var(--easing--ease-out)); + will-change: width; + + @media (prefers-reduced-motion: reduce) { + transition: none; + will-change: auto; + } +} + +@mixin height-transition { + transition: height var(--animation--height-transition--duration, var(--duration--snappy)) + var(--animation--height-transition--easing, var(--easing--ease-out)); + will-change: height; + + @media (prefers-reduced-motion: reduce) { + transition: none; + will-change: auto; + } +} diff --git a/packages/frontend/@n8n/design-system/src/styleguide/components/MotionExamples.vue b/packages/frontend/@n8n/design-system/src/styleguide/components/MotionExamples.vue new file mode 100644 index 00000000000..f2590e50fad --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/styleguide/components/MotionExamples.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/styleguide/motion.mdx b/packages/frontend/@n8n/design-system/src/styleguide/motion.mdx new file mode 100644 index 00000000000..642f986eaf9 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/styleguide/motion.mdx @@ -0,0 +1,104 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import MotionExamples from './components/MotionExamples.vue'; + + + +# Motion + +Motion should make n8n feel responsive, understandable, and calm. Use animation to explain +state changes, guide attention, or show progress. Avoid motion that is decorative, slow, +distracting, or repeated so often that it gets in the way. + +Motion utilities live in `@n8n/design-system/src/css/mixins/motion.scss`. + +## Principles + +- **Keep motion purposeful**: Use motion when it helps users understand what has changed (e.g loading states, enter/existing surfaces). Don't add motion for the sake of it. +- **Prefer subtle movement**: Most motion should be short and low-distance. Small transitions, such as opacity and translations, are enough. Don't animate rapid frequently interactions (e.g hover states) +- **Prefer non re-painting values**: Opacity and transform are the safest animation properties for performance. Use caution with layout and paint properties like height, width, box-shadow, background-color, and filter. When using those properties, add `will-change`. +- **Always aim for +60fps**: Motion should feel smooth and responsive. If an animation causes jank or lag, simplify the animation or use a more performant approach. +- **Respect reduced motion**: Always provide a non-animated fallback for users who prefer reduced motion. Motion mixins should handle `prefers-reduced-motion` internally so that consumers can use them without repeating media queries. +- **Easing**: Use easing to make motion feel more natural. Avoid linear easing for UI interactions, as it can feel mechanical and less responsive. Instead, use `ease-out` for entrances and exits, and `ease-in-out` for movements of already visible elements. Avoid `ease-in` for most UI animations, as it can delay feedback and make the interface feel sluggish. + +## Available Mixins + +| Mixin | Use for | Notes | +| ------------------------------- | ----------------------------------- | ---------------------------------------------------------- | +| `motion.spin` | Loading indicators | Continuous rotation. | +| `motion.skeleton-pulse` | Skeleton loading placeholders | Use for placeholder surfaces, not interactive elements. | +| `motion.shimmer` | Text or inline loading states | Best for short-lived AI or thinking states. | +| `motion.popover-in` | Dropdowns, menus, floating surfaces | Uses opacity, translate, and scale. | +| `motion.collapsible-slide-down` | Opening collapsible content | Animates height. Use only when height animation is needed. | +| `motion.collapsible-slide-up` | Closing collapsible content | Animates height. Use only when height animation is needed. | +| `motion.fade-in` | Simple entrance | Prefer for subtle one-off reveals. | +| `motion.fade-in-up` | Entrance from below | Use for vertical reveal patterns. | +| `motion.fade-in-down` | Entrance from above | Use when the element visually comes from above. | +| `motion.fade-in-left` | Entrance from left | Use for horizontal directional context. | +| `motion.fade-in-right` | Entrance from right | Use for horizontal directional context. | +| `motion.fade-out` | Simple exit | Use for removal without spatial movement. | +| `motion.fade-out-down` | Exit downward | Pair with matching entrance direction when possible. | +| `motion.fade-out-up` | Exit upward | Pair with matching entrance direction when possible. | +| `motion.fade-out-left` | Exit left | Pair with matching entrance direction when possible. | +| `motion.fade-out-right` | Exit right | Pair with matching entrance direction when possible. | +| `motion.blink-background` | Cursor-like attention states | Avoid for large areas or frequent UI. | +| `motion.typing-blink` | Typing indicators | Use for small repeated indicators only. | +| `motion.pulse-glow` | Rare emphasis or loading pulse | Animates `box-shadow`; use sparingly. | +| `motion.pulse-glow-delayed` | Secondary delayed pulse | Only use with `pulse-glow`. | +| `motion.width-transition` | Width changes | Layout-affecting. Use cautiously. | +| `motion.height-transition` | Height changes | Layout-affecting. Use cautiously. | + +## Using Mixins + +Motion mixins expose CSS variables for local customization. Prefer local overrides over +creating new keyframes. + +```scss +.dropdown { + --animation--popover-in--duration: var(--duration--snappy); + --animation--popover-in--translate-y: var(--spacing--4xs); + + @include motion.popover-in; +} +``` + +## Examples + + + +## Adding New Mixins + +Before adding a new mixin, check whether an existing mixin can be customized with CSS +variables. + +Add a new mixin only when: + +- The motion pattern is reused by multiple components. +- The animation has a clear product purpose. +- The behavior cannot be expressed cleanly with existing mixins. +- The mixin includes reduced-motion handling. +- The mixin uses design-system duration and easing tokens. + +New mixins should follow this shape: + +```scss +@mixin example-motion { + animation: exampleMotion var(--animation--example-motion--duration, var(--duration--snappy)) + var(--animation--example-motion--easing, var(--easing--ease-out)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes exampleMotion { + from { + opacity: 0; + transform: translateY(var(--animation--example-motion--translate, var(--spacing--4xs))); + } + + to { + opacity: 1; + transform: translateY(0); + } +} +``` diff --git a/packages/frontend/@n8n/design-system/src/v2/components/Loading/Loading.vue b/packages/frontend/@n8n/design-system/src/v2/components/Loading/Loading.vue index fda7d16d9cf..9a05a6538c0 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/Loading/Loading.vue +++ b/packages/frontend/@n8n/design-system/src/v2/components/Loading/Loading.vue @@ -84,17 +84,7 @@ function isLastRow(index: number, total: number): boolean { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatTypingIndicator.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatTypingIndicator.vue index 2a1b243ea59..61f6b59c799 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatTypingIndicator.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatTypingIndicator.vue @@ -3,6 +3,8 @@ diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue index aa2d2a9dd5f..02e449dddae 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonAgentCard.vue @@ -12,6 +12,8 @@ diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonMenuItem.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonMenuItem.vue index 5f5523aefcb..2fce9079dbd 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonMenuItem.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/SkeletonMenuItem.vue @@ -6,6 +6,8 @@ diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnimatedCollapsibleContent.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnimatedCollapsibleContent.vue index b7dbca6fba3..66899680420 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnimatedCollapsibleContent.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AnimatedCollapsibleContent.vue @@ -9,33 +9,19 @@ import { CollapsibleContent } from 'reka-ui'; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMessage.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMessage.vue index 4b774a56758..1609a7793a1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMessage.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMessage.vue @@ -175,6 +175,8 @@ function formatJson(value: unknown): string {