mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-17 17:42:47 +08:00
refactor(editor): Add motion.scss utilities to standardise animations and transitions (#29704)
This commit is contained in:
@@ -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',
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ const statusColor = computed(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../../../css/mixins/animations';
|
||||
@use '../../../css/mixins/motion';
|
||||
|
||||
.toolMessage {
|
||||
width: 100%;
|
||||
|
||||
+5
-11
@@ -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>
|
||||
|
||||
+5
-23
@@ -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);
|
||||
|
||||
+12
-12
@@ -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 {
|
||||
|
||||
+3
-10
@@ -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 {
|
||||
|
||||
+19
-26
@@ -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
-14
@@ -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
-12
@@ -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>
|
||||
|
||||
+7
-11
@@ -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>
|
||||
|
||||
+6
-20
@@ -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>
|
||||
|
||||
+6
-11
@@ -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;
|
||||
|
||||
+5
-12
@@ -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);
|
||||
|
||||
+3
-10
@@ -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 {
|
||||
|
||||
+7
-15
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user