refactor(editor): Add motion.scss utilities to standardise animations and transitions (#29704)

This commit is contained in:
Rob Hough
2026-05-05 07:34:43 +00:00
committed by GitHub
parent 7c0d3ccb39
commit c6cbc49016
29 changed files with 1120 additions and 353 deletions
@@ -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)`
@@ -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
@@ -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',
@@ -78,7 +78,7 @@ function getIconForStatus(status: ChatUI.ThinkingItem['status']) {
</template>
<style lang="scss" module>
@use '../../../css/mixins/animations';
@use '../../../css/mixins/motion';
.thinkingContainer {
margin: var(--spacing--4xs) 0;
@@ -124,7 +124,7 @@ function getIconForStatus(status: ChatUI.ThinkingItem['status']) {
}
.shimmer {
@include animations.shimmer;
@include motion.shimmer;
}
.itemList {
@@ -92,7 +92,7 @@ const statusColor = computed(() => {
</template>
<style lang="scss" module>
@use '../../../css/mixins/animations';
@use '../../../css/mixins/motion';
.toolMessage {
width: 100%;
@@ -3,23 +3,17 @@
</template>
<style lang="scss">
@use '../../css/mixins/motion';
.blinking-cursor {
--animation--blink-background--duration: 1s;
display: inline-block;
height: var(--font-size--md);
width: var(--spacing--3xs);
border-radius: var(--radius--sm);
margin-left: var(--spacing--4xs);
animation: 1s blink step-end infinite;
}
@keyframes blink {
from,
to {
background-color: transparent;
}
50% {
background-color: var(--color--foreground--shade-2);
}
@include motion.blink-background;
}
</style>
@@ -127,6 +127,7 @@ const handleClick = (event: MouseEvent) => {
<style lang="scss" module>
@use '../../css/mixins/focus';
@use '../../css/mixins/motion';
.button {
appearance: none;
@@ -408,11 +409,7 @@ const handleClick = (event: MouseEvent) => {
display: flex;
align-items: center;
justify-content: center;
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
@include motion.spin;
}
/* TODO: Move to global animations css library */
@@ -439,13 +436,4 @@ const handleClick = (event: MouseEvent) => {
filter: none;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -80,6 +80,8 @@ const isOpen = computed({
</template>
<style lang="scss" module>
@use '../../css/mixins/motion';
.collapsiblePanel {
position: relative;
border: var(--border-width) var(--border-style) var(--color--foreground);
@@ -211,13 +213,11 @@ const isOpen = computed({
}
&[data-state='open'] {
animation: slideDown var(--animation--duration--collapse, var(--animation--duration))
var(--animation--easing);
@include motion.collapsible-slide-down;
}
&[data-state='closed'] {
animation: slideUp var(--animation--duration--collapse, var(--animation--duration))
var(--animation--easing);
@include motion.collapsible-slide-up;
}
> :first-child {
@@ -226,24 +226,6 @@ const isOpen = computed({
}
.noAnimation {
--animation--duration--collapse: 0s;
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
}
--animation--collapsible-slide--duration: 0s;
}
</style>
@@ -129,6 +129,8 @@ defineExpose({
</template>
<style lang="scss" module>
@use '../../css/mixins/motion';
.defaultTrigger {
display: inline-flex;
align-items: center;
@@ -198,6 +200,11 @@ defineExpose({
}
.content {
--animation--popover-in--translate-x: 0;
--animation--popover-in--translate-y: 0;
--dropdown--transform-origin-x: center;
--dropdown--transform-origin-y: center;
min-width: 200px;
max-width: 400px;
max-height: 320px;
@@ -208,29 +215,57 @@ defineExpose({
z-index: 9999;
overflow: auto;
padding: var(--spacing--3xs);
transform-origin: var(--dropdown--transform-origin-x) var(--dropdown--transform-origin-y);
&[data-state='open'] {
--animation--popover-in--duration: var(--animation--duration);
--animation--popover-in--easing: var(--animation--easing);
@include motion.popover-in;
}
&[data-side='bottom'] {
transform-origin: top center;
&[data-state='open'] {
animation: slideDown var(--animation--duration) var(--animation--easing);
}
&[data-state='closed'] {
animation: slideDown var(--animation--duration) var(--animation--easing) reverse;
}
--animation--popover-in--translate-y: 2px;
--dropdown--transform-origin-y: top;
}
&[data-side='top'] {
transform-origin: bottom center;
--animation--popover-in--translate-y: -2px;
--dropdown--transform-origin-y: bottom;
}
&[data-state='open'] {
animation: slideUp var(--animation--duration) var(--animation--easing);
}
&[data-side='right'] {
--animation--popover-in--translate-x: 2px;
--dropdown--transform-origin-x: left;
}
&[data-state='closed'] {
animation: slideUp var(--animation--duration) var(--animation--easing) reverse;
}
&[data-side='left'] {
--animation--popover-in--translate-x: -2px;
--dropdown--transform-origin-x: right;
}
&[data-side='top'][data-align='start'],
&[data-side='bottom'][data-align='start'] {
--animation--popover-in--translate-x: -2px;
--dropdown--transform-origin-x: left;
}
&[data-side='top'][data-align='end'],
&[data-side='bottom'][data-align='end'] {
--animation--popover-in--translate-x: 2px;
--dropdown--transform-origin-x: right;
}
&[data-side='left'][data-align='start'],
&[data-side='right'][data-align='start'] {
--animation--popover-in--translate-y: -2px;
--dropdown--transform-origin-y: top;
}
&[data-side='left'][data-align='end'],
&[data-side='right'][data-align='end'] {
--animation--popover-in--translate-y: 2px;
--dropdown--transform-origin-y: bottom;
}
}
@@ -270,26 +305,4 @@ defineExpose({
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px) scaleY(0.95);
}
to {
opacity: 1;
transform: translateY(0) scaleY(1);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(4px) scaleY(0.95);
}
to {
opacity: 1;
transform: translateY(0) scaleY(1);
}
}
</style>
@@ -125,6 +125,8 @@ const resolvedComponent = computed(
</template>
<style lang="scss" module>
@use '../../css/mixins/motion';
.strokeWidth {
rect,
path {
@@ -133,15 +135,6 @@ const resolvedComponent = computed(
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
@include motion.spin;
}
</style>
@@ -13,19 +13,7 @@ defineOptions({ name: 'N8nPulse' });
</template>
<style lang="scss" module>
$--light-pulse-color: hsla(
var(--color--primary--h),
var(--color--primary--s),
var(--color--primary--l),
0.4
);
$--dark-pulse-color: hsla(
var(--color--primary--h),
var(--color--primary--s),
var(--color--primary--l),
0
);
@use '../../css/mixins/motion';
.pulseContainer {
display: flex;
@@ -39,8 +27,9 @@ $--dark-pulse-color: hsla(
width: 74px;
height: 74px;
border-radius: 50%;
box-shadow: 0 0 0 $--light-pulse-color;
animation: pulse 6s infinite cubic-bezier(0.33, 1, 0.68, 1);
box-shadow: 0 0 0
hsla(var(--color--primary--h), var(--color--primary--s), var(--color--primary--l), 0.4);
@include motion.pulse-glow;
}
.pulse2 {
@@ -50,61 +39,8 @@ $--dark-pulse-color: hsla(
width: 74px;
height: 74px;
border-radius: 50%;
box-shadow: 0 0 0 $--light-pulse-color;
animation: pulse2 6s infinite cubic-bezier(0.33, 1, 0.68, 1);
}
@keyframes pulse {
0% {
-moz-box-shadow: 0 0 0 0 $--light-pulse-color;
box-shadow: 0 0 0 0 $--light-pulse-color;
}
58.33% {
// 3s
-moz-box-shadow: 0 0 0 60px $--dark-pulse-color;
box-shadow: 0 0 0 60px $--dark-pulse-color;
}
66.6% {
// 4s
-moz-box-shadow: 0 0 0 66px transparent;
box-shadow: 0 0 0 66px transparent;
}
66.7% {
-moz-box-shadow: 0 0 0 0 transparent;
box-shadow: 0 0 0 0 transparent;
}
}
@keyframes pulse2 {
0% {
-moz-box-shadow: 0 0 0 0 $--light-pulse-color;
box-shadow: 0 0 0 0 $--light-pulse-color;
}
16.66% {
// 1s
-moz-box-shadow: 0 0 0 0 $--light-pulse-color;
box-shadow: 0 0 0 0 $--light-pulse-color;
}
50% {
// 3s
-moz-box-shadow: 0 0 0 60px $--dark-pulse-color;
box-shadow: 0 0 0 60px $--dark-pulse-color;
}
83.3% {
// 5s
-moz-box-shadow: 0 0 0 66px transparent;
box-shadow: 0 0 0 66px transparent;
}
83.4% {
-moz-box-shadow: 0 0 0 0 transparent;
box-shadow: 0 0 0 0 transparent;
}
box-shadow: 0 0 0
hsla(var(--color--primary--h), var(--color--primary--s), var(--color--primary--l), 0.4);
@include motion.pulse-glow-delayed;
}
</style>
@@ -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);
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { ref } from 'vue';
import N8nButton from '../../components/N8nButton/Button.vue';
import N8nCollapsiblePanel from '../../components/N8nCollapsiblePanel/CollapsiblePanel.vue';
import N8nPopover from '../../components/N8nPopover/Popover.vue';
type ExampleKey = 'spinner' | 'skeleton' | 'popover' | 'collapsible' | 'fadeIn' | 'fadeOut';
const exampleKeys: ExampleKey[] = [
'spinner',
'skeleton',
'popover',
'collapsible',
'fadeIn',
'fadeOut',
];
const refreshKeys = ref<Record<ExampleKey, number>>(
exampleKeys.reduce(
(keys, key) => ({
...keys,
[key]: 0,
}),
{} as Record<ExampleKey, number>,
),
);
const popoverOpen = ref(true);
const collapsibleOpen = ref(true);
function refreshExample(example: ExampleKey) {
refreshKeys.value[example] += 1;
if (example === 'popover') {
popoverOpen.value = true;
}
if (example === 'collapsible') {
collapsibleOpen.value = true;
}
}
</script>
<template>
<div class="motion-demo-grid">
<div class="motion-demo-card">
<div class="motion-demo-header">
<div class="motion-demo-label">Spinner</div>
<N8nButton
icon-only
icon="refresh-cw"
size="small"
variant="ghost"
aria-label="Refresh spinner example"
@click="refreshExample('spinner')"
/>
</div>
<div :key="refreshKeys.spinner" class="motion-demo-spinner" />
</div>
<div class="motion-demo-card">
<div class="motion-demo-header">
<div class="motion-demo-label">Skeleton pulse</div>
<N8nButton
icon-only
icon="refresh-cw"
size="small"
variant="ghost"
aria-label="Refresh skeleton pulse example"
@click="refreshExample('skeleton')"
/>
</div>
<div :key="refreshKeys.skeleton" class="motion-demo-skeleton">
<div />
<div />
<div />
</div>
</div>
<div class="motion-demo-card">
<div class="motion-demo-header">
<div class="motion-demo-label">N8nPopover</div>
<N8nButton
icon-only
icon="refresh-cw"
size="small"
variant="ghost"
aria-label="Refresh popover example"
@click="refreshExample('popover')"
/>
</div>
<div :key="refreshKeys.popover" class="motion-demo-component-stage">
<N8nPopover
v-model:open="popoverOpen"
:teleported="false"
:enable-scrolling="false"
:suppress-auto-focus="true"
width="180px"
>
<template #trigger>
<N8nButton size="small" variant="subtle">Open popover</N8nButton>
</template>
<template #content>
<div class="motion-demo-popover-content">Animated popover surface</div>
</template>
</N8nPopover>
</div>
</div>
<div class="motion-demo-card">
<div class="motion-demo-header">
<div class="motion-demo-label">N8nCollapsiblePanel</div>
<N8nButton
icon-only
icon="refresh-cw"
size="small"
variant="ghost"
aria-label="Refresh collapsible panel example"
@click="refreshExample('collapsible')"
/>
</div>
<div :key="refreshKeys.collapsible" class="motion-demo-component-stage">
<N8nCollapsiblePanel v-model="collapsibleOpen" title="Panel title">
<div class="motion-demo-collapsible-content">Expanded content</div>
</N8nCollapsiblePanel>
</div>
</div>
<div class="motion-demo-card">
<div class="motion-demo-header">
<div class="motion-demo-label">Fade in</div>
<N8nButton
icon-only
icon="refresh-cw"
size="small"
variant="ghost"
aria-label="Refresh fade in example"
@click="refreshExample('fadeIn')"
/>
</div>
<div :key="refreshKeys.fadeIn" class="motion-demo-fade-in">Content entering</div>
</div>
<div class="motion-demo-card">
<div class="motion-demo-header">
<div class="motion-demo-label">Fade out</div>
<N8nButton
icon-only
icon="refresh-cw"
size="small"
variant="ghost"
aria-label="Refresh fade out example"
@click="refreshExample('fadeOut')"
/>
</div>
<div :key="refreshKeys.fadeOut" class="motion-demo-fade-out">Content leaving</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../../css/mixins/motion';
.motion-demo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing--md);
margin-top: var(--spacing--md);
}
.motion-demo-card {
display: flex;
min-height: 164px;
align-items: center;
justify-content: start;
flex-direction: column;
gap: var(--spacing--sm);
padding: var(--spacing--sm);
border: 1px solid var(--border-color);
border-radius: var(--radius--lg);
background: var(--background--surface);
}
.motion-demo-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--spacing--xs);
}
.motion-demo-label {
color: var(--text-color--light);
font-size: var(--font-size--xs);
font-weight: var(--font-weight--semibold);
}
.motion-demo-refresh {
padding: var(--spacing--5xs) var(--spacing--3xs);
border: 1px solid var(--border-color);
border-radius: var(--radius--sm);
background: var(--background--surface);
color: var(--text-color);
cursor: pointer;
font-size: var(--font-size--2xs);
}
.motion-demo-spinner {
width: var(--spacing--xl);
height: var(--spacing--xl);
border: 2px solid var(--border-color);
border-top-color: var(--color--primary);
border-radius: 50%;
@include motion.spin;
}
.motion-demo-skeleton {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--spacing--2xs);
> div {
height: var(--spacing--sm);
border-radius: var(--radius--sm);
background: var(--color--foreground);
@include motion.skeleton-pulse;
}
> div:nth-child(2) {
width: 80%;
}
> div:nth-child(3) {
width: 56%;
}
}
.motion-demo-component-stage {
width: 100%;
}
.motion-demo-popover-content,
.motion-demo-collapsible-content,
.motion-demo-fade-in,
.motion-demo-fade-out {
padding: var(--spacing--xs) var(--spacing--sm);
border-radius: var(--radius--md);
color: var(--text-color);
}
.motion-demo-fade-in {
@include motion.fade-in;
}
.motion-demo-fade-out {
@include motion.fade-out;
}
</style>
@@ -0,0 +1,104 @@
import { Meta } from '@storybook/addon-docs/blocks';
import MotionExamples from './components/MotionExamples.vue';
<Meta title="Styleguide/Motion" />
# 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
<MotionExamples />
## 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);
}
}
```
@@ -84,17 +84,7 @@ function isLastRow(index: number, total: number): boolean {
</template>
<style lang="scss" module>
@keyframes skeleton-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
@use '../../../css/mixins/motion';
.loading {
display: block;
@@ -111,7 +101,9 @@ function isLastRow(index: number, total: number): boolean {
}
.animated {
animation: skeleton-pulse 1.5s ease-in-out infinite;
--animation--skeleton-pulse--duration: 1.5s;
@include motion.skeleton-pulse;
}
// Variant-specific styles
@@ -1,3 +1,6 @@
/**
* @depreacted - Use motion.scss mixin or <Transition/ > instead
**/
.executions-list-move,
.executions-list-enter-active {
transition: all 1.5s cubic-bezier(0.19, 1, 0.22, 1);
@@ -161,6 +161,8 @@ const nodeTypeForIcon = computed((): SimplifiedNodeType | null => {
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.card {
position: relative;
display: flex;
@@ -241,30 +243,28 @@ const nodeTypeForIcon = computed((): SimplifiedNodeType | null => {
}
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
}
.skeletonIcon {
--animation--skeleton-pulse--duration: 1.5s;
--animation--skeleton-pulse--opacity-start: 0.6;
--animation--skeleton-pulse--opacity-end: 0.3;
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--color--foreground);
animation: skeleton-pulse 1.5s ease-in-out infinite;
@include motion.skeleton-pulse;
}
.skeletonText {
--animation--skeleton-pulse--duration: 1.5s;
--animation--skeleton-pulse--opacity-start: 0.6;
--animation--skeleton-pulse--opacity-end: 0.3;
width: 80px;
height: 14px;
border-radius: var(--radius--sm);
background: var(--color--foreground);
animation: skeleton-pulse 1.5s ease-in-out infinite;
@include motion.skeleton-pulse;
}
.iconContainer {
@@ -92,6 +92,8 @@ const warningTooltip = computed<string | undefined>(() => {
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.fileRow {
display: flex;
align-items: center;
@@ -141,16 +143,7 @@ const warningTooltip = computed<string | undefined>(() => {
.iconIndexing {
color: var(--color--text--tint-1);
flex-shrink: 0;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
@include motion.spin;
}
.statusText {
@@ -178,6 +178,8 @@ watch(isOpen, (open, _oldValue, onCleanup) => {
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.trigger {
display: inline-flex;
align-items: center;
@@ -211,6 +213,13 @@ watch(isOpen, (open, _oldValue, onCleanup) => {
}
.content {
--animation--popover-in--duration: 200ms;
--animation--popover-in--easing: cubic-bezier(0.16, 1, 0.3, 1);
--animation--popover-in--translate-x: 0;
--animation--popover-in--translate-y: 0;
--canvas-chat-session-dropdown--transform-origin-x: center;
--canvas-chat-session-dropdown--transform-origin-y: center;
min-width: 240px;
max-width: 320px;
max-height: 360px;
@@ -223,13 +232,21 @@ watch(isOpen, (open, _oldValue, onCleanup) => {
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
padding: var(--spacing--3xs);
z-index: 9999;
transform-origin: var(--canvas-chat-session-dropdown--transform-origin-x)
var(--canvas-chat-session-dropdown--transform-origin-y);
&[data-state='open'] {
@include motion.popover-in;
}
&[data-side='bottom'] {
animation: slideDown 200ms cubic-bezier(0.16, 1, 0.3, 1);
--animation--popover-in--translate-y: 2px;
--canvas-chat-session-dropdown--transform-origin-y: top;
}
&[data-side='top'] {
animation: slideUp 200ms cubic-bezier(0.16, 1, 0.3, 1);
--animation--popover-in--translate-y: -2px;
--canvas-chat-session-dropdown--transform-origin-y: bottom;
}
}
@@ -279,28 +296,4 @@ watch(isOpen, (open, _oldValue, onCleanup) => {
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -3,6 +3,8 @@
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.typing {
display: inline-flex;
gap: 6px;
@@ -14,7 +16,7 @@
border-radius: 50%;
background: currentColor;
opacity: 0.35;
animation: blink 1.2s infinite;
@include motion.typing-blink;
}
.typing i:nth-child(2) {
@@ -24,17 +26,4 @@
.typing i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.35;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-2px);
}
}
</style>
@@ -12,6 +12,8 @@
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.card {
display: flex;
align-items: center;
@@ -23,18 +25,26 @@
}
.skeleton {
--animation--skeleton-pulse--duration: 1s;
--animation--skeleton-pulse--opacity-start: 0.6;
--animation--skeleton-pulse--opacity-end: 0.3;
background: var(--color--foreground);
animation: skeleton-pulse 1s ease-in-out infinite;
@include motion.skeleton-pulse;
border-radius: var(--radius--sm);
}
.avatar {
--animation--skeleton-pulse--duration: 1s;
--animation--skeleton-pulse--opacity-start: 0.6;
--animation--skeleton-pulse--opacity-end: 0.3;
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
background: var(--color--foreground);
animation: skeleton-pulse 1s ease-in-out infinite;
@include motion.skeleton-pulse;
}
.content {
@@ -60,14 +70,4 @@
height: 24px;
border-radius: var(--radius);
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
}
</style>
@@ -6,6 +6,8 @@
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.skeletonItem {
display: flex;
align-items: center;
@@ -17,8 +19,12 @@
.skeletonAvatar,
.skeletonText {
--animation--skeleton-pulse--duration: 1s;
--animation--skeleton-pulse--opacity-start: 0.6;
--animation--skeleton-pulse--opacity-end: 0.3;
background: var(--color--foreground);
animation: skeleton-pulse 1s ease-in-out infinite;
@include motion.skeleton-pulse;
}
.skeletonAvatar {
@@ -33,14 +39,4 @@
width: 80%;
border-radius: var(--radius--sm);
}
@keyframes skeleton-pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 0.3;
}
}
</style>
@@ -9,33 +9,19 @@ import { CollapsibleContent } from 'reka-ui';
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.content {
overflow: hidden;
&[data-state='open'] {
animation: slideDown 0.2s ease-out;
--animation--collapsible-slide--duration: 0.2s;
@include motion.collapsible-slide-down;
}
&[data-state='closed'] {
animation: slideUp 0.2s ease-out;
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--reka-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--reka-collapsible-content-height);
}
to {
height: 0;
--animation--collapsible-slide--duration: 0.2s;
@include motion.collapsible-slide-up;
}
}
</style>
@@ -175,6 +175,8 @@ function formatJson(value: unknown): string {
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.userMessage {
align-self: flex-end;
display: flex;
@@ -232,11 +234,14 @@ function formatJson(value: unknown): string {
}
.statusDot {
--animation--opacity-pulse--duration: 1.5s;
--animation--opacity-pulse--opacity-end: 0.3;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color--primary);
animation: pulse 1.5s ease-in-out infinite;
@include motion.opacity-pulse;
}
@keyframes status-fade-in {
@@ -248,16 +253,6 @@ function formatJson(value: unknown): string {
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.blinkingCursor {
display: inline-block;
width: 2px;
@@ -121,6 +121,8 @@ onUnmounted(() => {
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.bar {
display: flex;
align-items: center;
@@ -146,25 +148,16 @@ onUnmounted(() => {
}
.dot {
--animation--opacity-pulse--duration: 1.5s;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color--primary);
animation: pulse 1.5s ease-in-out infinite;
@include motion.opacity-pulse;
flex-shrink: 0;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.label {
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
@@ -1077,6 +1077,8 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.card {
width: 100%;
display: flex;
@@ -1199,16 +1201,7 @@ const nodeNamesTooltip = computed(() => nodeNames.value.join(', '));
.loading {
color: var(--color--text--tint-1);
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
@include motion.spin;
}
.submitted {
@@ -32,6 +32,8 @@ defineSlots<{
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
.block {
max-width: 90%;
justify-content: flex-start;
@@ -55,27 +57,17 @@ defineSlots<{
line-height: normal;
}
// Shimmer animation for active section headers
.shimmer {
--animation--shimmer--duration: 1.5s;
--animation--shimmer--background: var(--color--text--tint-1);
--animation--shimmer--foreground: var(--color--text--tint-2);
background: linear-gradient(
90deg,
var(--color--text--tint-1) 25%,
var(--color--text--tint-2) 50%,
var(--color--text--tint-1) 75%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
@include motion.shimmer;
}
</style>