Foundation
Motion
Motion reveals process — it never decorates. Every animation uses a named duration token and a standard easing curve. The ceiling is 300ms.
01 — Philosophy
Nodus motion follows one rule: if removing the animation breaks understanding, it stays; if removing it changes nothing, it goes.
A pulsing dot on a streaming indicator communicates “the agent is working.” Remove it and the user sees a static dot — the meaning is lost. That animation earns its place. A slide-in on a static card? Decoration. Cut it.
This aligns with Principle 04 — Temporal Honesty: motion should communicate real state changes over time, not create false impressions of activity.
02 — Duration Tokens
Five named durations from imperceptible to deliberate. Click any bar to see the timing in real time.
Micro-feedback, focus rings, checkbox toggles
Button press feedback, toggle switches, state dots
Hover states, tooltips, dropdown reveals
Panel reveals, content appearance, sidebar expand
Modal opens, major layout shifts, page transitions
Error shake feedback (MFAInput, form validation)
Cursor-style blink for streaming text indicators
Live/active state pulse (StatusDot, StreamingDot, DataFreshness)
Loading placeholder shimmer animation
03 — Easing Curves
Three easing functions. The default smooth curve handles 95% of use cases — reach for sharp or linear only when justified.
Default for all transitions. Natural deceleration.
Snappier variant for small interactive elements. Slight overshoot feeling.
Progress bars, loading indicators, spinners. Constant velocity.
04 — Keyframe Catalog
Eight named keyframes defined in globals.css. Each has a live demo — click to play.
Rhythmic opacity pulse for active/streaming indicators.
@keyframes ds-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}Hard on/off blink for cursor-style indicators.
@keyframes ds-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}Shimmer effect for loading placeholder content.
@keyframes ds-skeleton {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}Simple opacity entrance. The most common entry animation.
@keyframes ds-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}Subtle flash when a status indicator changes value.
@keyframes ds-indicator-swap {
from { opacity: 0.6; }
to { opacity: 1; }
}Slide-in from left for audit trail / log entries.
@keyframes ds-audit-enter {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}Wider slide-in for demo showcase elements.
@keyframes ds-demo-slide-in {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}Horizontal scale from zero for progress bars and connectors.
@keyframes ds-demo-scale-x {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}Subtle scale+opacity pulse for active graph nodes.
@keyframes ds-node-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
}05 — Agentic Transition Grammar
Agentic UI introduces three new easing curves tied to the three semantic color roles. Motion communicates what kind of change is happening — not just that something changed. The curve is the message.
Data updates, confidence meter fills. Fast start, smooth settle.
Agent state arrivals (idle→active), error escalation slide-in. Slight ease-in, strong deceleration.
Error dismissal, panel close, agent completion. Accelerates through exit — never lingers.
<motion.div
variants={motionVariants.agentArrive}
initial="hidden"
animate="visible"
/><motion.div
variants={motionVariants.errorSlideDown}
initial="hidden"
animate="visible"
/>transition: width var(--ds-motion-duration-default) var(--ds-motion-easing-productive);animation: ds-pulse 1.2s ease-in-out infinite;// Anti-pattern: do not do this
// text.split("").map((c, i) => <motion.span key={i} ...>)06 — Framer Motion Add-on
The Nodus motion tokens are CSS custom properties. When using Framer Motion, import the adapter from @/lib/motion-tokens — it converts ms → seconds and CSS cubic-bezier() → control-point arrays. DS protocol still applies: no springs for UI transitions, 300ms ceiling.
import { motion, AnimatePresence } from "framer-motion";
import {
motionTransition,
motionVariants,
motionDuration,
motionEase,
} from "@/lib/motion-tokens";// Transition presets — map 1:1 to DS semantic intent
motionTransition.snap // 80ms — button press, focus rings
motionTransition.fast // 150ms — hover states, tooltips
motionTransition.smooth // 200ms — panel reveals (default)
motionTransition.deliberate // 300ms — modal opens, page transitions
motionTransition.snappy // 150ms — sharp easing, small elements
motionTransition.progress // 300ms — progress bars, linear
// Usage:
<motion.div transition={motionTransition.smooth} animate={{ opacity: 1 }} /><motion.div
variants={motionVariants.fadeIn}
initial="hidden"
animate="visible"
/><motion.div
variants={motionVariants.slideUp}
initial="hidden"
animate="visible"
/><motion.div
variants={motionVariants.scaleIn}
initial="hidden"
animate="visible"
/><motion.ul variants={motionVariants.stagger} initial="hidden" animate="visible">
{items.map(i => (
<motion.li key={i} variants={motionVariants.staggerItem}>{i}</motion.li>
))}
</motion.ul>No springs for UI transitions. Springs are only permitted for drag interactions and physical gesture responses. type: "tween" is always the default. motionSpring.dragRelease is the only exported spring preset.
07 — Reduced Motion
Nodus respects prefers-reduced-motion: reduce at the system level. A single rule in globals.css collapses all animation durations to 0.01ms and iteration counts to 1.
This means your components need no extra code. The override is automatic. But you should verify that your component still communicates its state without animation — if the only signal is a pulse, add a static indicator too.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}