Files
GMW/design/core/04-motion-system.md
T
asepharyana d59b59a7a7 feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config
Frontend:
- migrate from Vite to Astro (astro.config.mjs, pages/, layouts/)
- add admin panel, settings page, command palette, error boundary
- refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout
- update API client, WebSocket, auth, dashboard features

Backend:
- add admin module and config routes
- refactor middlewares, Redis connection, WebSocket server/bridge
- add runtime config loader

Discord Gateway:
- refactor AI moderation: circuit breaker, concurrency limiter, fallback processor
- add media analysis client, Seaxng search, user profile learner
- add new drizzle migration

Shared:
- extend database schema, add new config fields
2026-07-02 00:02:41 +07:00

12 KiB
Raw Blame History

Motion System — The Dance of Glass

"Animation is not about making things move. It's about making things believe." — Richard Williams, The Animator's Survival Kit


🎯 Filosofi Gerak

Animasi di BETE bukan sekadar efek visual — ia adalah bahasa kinetik yang mengkomunikasikan relasi spasial antar elemen:

Gerakan Makna
Slide from right Elemen baru datang dari "luar" — panel, drawer
Fade in + scale Muncul dari "dalam" — modal, dialog
Slide up Konten baru melanjutkan alur vertikal
Scale + glow Sedang diproses — loading, analysis
Spring bounce (subtle) Konfirmasi sukses — centang, badge

⏱️ Timing & Easing — The Physics Engine

Duration Tokens

:root {
  --dur-instant:  0ms;
  --dur-fast:     150ms;   /* Hover, active state, toggle */
  --dur-normal:   250ms;   /* Default transition, card hover */
  --dur-slow:     350ms;   /* Panel enter/exit, page transition */
  --dur-glacial:  500ms;   /* Modal, drawer slide, emphasis */
}

Easing Curves

BETE menggunakan tiga easing curve utama, semuanya custom cubic-bezier:

:root {
  /* Standard ease-out — untuk sebagian besar interaksi */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* Ease-out quint — untuk elemen yang "mendarat" */
  --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);

  /* Ease-in-out — untuk transisi dua arah (accordion, collapse) */
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}

Perbandingan dengan CSS Default

Nama Bezier Karakter
ease (default) (0.25, 0.1, 0.25, 1) Lambat mulai, lambat akhir
ease-out (0, 0, 0.58, 1) Cepat mulai
ease-in-out (0.42, 0, 0.58, 1) Simetris
--ease-out (0.16, 1, 0.3, 1) Natural, "berat" di akhir
--ease-out-quint (0.22, 1, 0.36, 1) Landing yang tegas

🏃 Micro-interactions

Hover State

/* Card hover — subtle lift + shadow deepen */
.card {
  transition:
    transform var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-fast) var(--ease-out);
}

.card:hover {
  transform: translateY(-2px);
  box-shadow: var(--sh-hover);
}

/* Button hover — scale up subtly */
.button {
  transition: transform var(--dur-fast) var(--ease-out);
}
.button:hover {
  transform: scale(1.02);
}
.button:active {
  transform: scale(0.98);
}

Active/Press State

.button:active {
  transform: scale(0.96);
  transition-duration: var(--dur-fast);
}

/* Button ripple effect */
.button.ripple {
  position: relative;
  overflow: hidden;
}

.button.ripple::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle, rgba(255,255,255,0.3) 10%, transparent 10%);
  background-position: center;
  background-repeat: no-repeat;
  background-size: 1000% 1000%;
  opacity: 0;
  transition: none;
}

.button.ripple:active::after {
  background-size: 0% 0%;
  opacity: 1;
  transition: background-size 0.4s, opacity 0.4s;
}

Focus Ring

.button:focus-visible {
  outline: 2px solid var(--clr-primary-400);
  outline-offset: 2px;
}

/* Smooth ring transition */
.input {
  transition:
    border-color var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-fast) var(--ease-out);
}
.input:focus {
  border-color: var(--clr-primary);
  box-shadow: 0 0 0 3px var(--clr-primary-bg);
}

🎬 Page & Panel Transitions

Tab Switch — Choreographed Sequence

/* Stagger container */
.page-transition-enter {
  opacity: 0;
}

.page-transition-enter-active {
  opacity: 1;
  transition: opacity var(--dur-slow) var(--ease-out);
}

/* Children stagger — via Framer Motion / GSAP timeline */
@keyframes stagger-enter {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.stagger-item {
  animation: stagger-enter var(--dur-slow) var(--ease-out) both;
}

.stagger-item:nth-child(1) { animation-delay: 40ms; }
.stagger-item:nth-child(2) { animation-delay: 80ms; }
.stagger-item:nth-child(3) { animation-delay: 120ms; }
.stagger-item:nth-child(4) { animation-delay: 160ms; }
.stagger-item:nth-child(5) { animation-delay: 200ms; }
/* ... formula: delay = index * 40ms */

Vue Transition Mode

<template>
  <Transition
    name="page"
    mode="out-in"
    @before-leave="beforeLeave"
    @after-enter="afterEnter"
  >
    <component :is="currentTab" :key="currentTab" />
  </Transition>
</template>

<style>
.page-leave-active {
  transition: opacity var(--dur-normal) var(--ease-in-out),
              transform var(--dur-normal) var(--ease-in-out);
}
.page-leave-to {
  opacity: 0;
  transform: translateY(-8px);
}
.page-enter-active {
  transition: opacity var(--dur-slow) var(--ease-out),
              transform var(--dur-slow) var(--ease-out);
}
.page-enter-from {
  opacity: 0;
  transform: translateY(12px);
}
</style>

🌟 Component-Specific Animations

Sidebar Expand/Collapse

.sidebar {
  width: var(--sidebar-width, 256px);
  transition: width var(--dur-slow) var(--ease-out-quint);
}

.sidebar.collapsed {
  --sidebar-width: 64px;
}

/* Nav items — icon slides, text fades */
.sidebar-nav-item {
  display: flex;
  align-items: center;
  gap: 12px;
  overflow: hidden;
}

.sidebar-nav-item .label {
  transition: opacity var(--dur-normal) var(--ease-out),
              width var(--dur-normal) var(--ease-out);
  white-space: nowrap;
}

.sidebar.collapsed .sidebar-nav-item .label {
  opacity: 0;
  width: 0;
  padding: 0;
}

Toast Notification

.toast-enter-active {
  animation: toast-slide-in var(--dur-slow) var(--ease-out-quint);
}

.toast-leave-active {
  animation: toast-slide-out var(--dur-normal) var(--ease-in-out);
}

@keyframes toast-slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

@keyframes toast-slide-out {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(100%);
    opacity: 0;
  }
}

Modal / Dialog

.modal-overlay-enter-active {
  transition: opacity var(--dur-normal) var(--ease-out);
}
.modal-overlay-enter-from { opacity: 0; }

.modal-content-enter-active {
  animation: modal-scale-in var(--dur-slow) var(--ease-out-quint);
}

@keyframes modal-scale-in {
  from {
    transform: scale(0.92) translateY(8px);
    opacity: 0;
  }
  to {
    transform: scale(1) translateY(0);
    opacity: 1;
  }
}

📊 Special Effects

Audio Visualizer Bars

.visualizer-bar {
  animation: bar-pulse 0.4s ease-in-out infinite;
  transform-origin: bottom;
}

.visualizer-bar:nth-child(1) { animation-delay: 0ms; }
.visualizer-bar:nth-child(2) { animation-delay: 75ms; }
.visualizer-bar:nth-child(3) { animation-delay: 150ms; }
.visualizer-bar:nth-child(4) { animation-delay: 225ms; }

@keyframes bar-pulse {
  0%, 100% { transform: scaleY(0.8); }
  50%      { transform: scaleY(1.2); }
}

Skeleton Loading (Shimmer)

.skeleton {
  background: linear-gradient(
    90deg,
    var(--clr-surface-sunken) 25%,
    var(--clr-surface-elevated) 50%,
    var(--clr-surface-sunken) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

AI Analysis Pulse (Glow)

.ai-badge {
  animation: ai-pulse 2s ease-in-out infinite;
}

@keyframes ai-pulse {
  0%, 100% {
    box-shadow: 0 0 4px var(--clr-rose-500 / 0.2);
  }
  50% {
    box-shadow: 0 0 12px var(--clr-rose-500 / 0.4);
  }
}

Ghost Particle (Background)

.particle {
  position: fixed;
  border-radius: 50%;
  pointer-events: none;
  animation: float var(--dur-float, 8s) ease-in-out infinite;
  animation-delay: var(--delay, 0s);
}

@keyframes float {
  0%, 100% {
    transform: translateY(0) translateX(0) scale(1);
    opacity: 0.3;
  }
  25% {
    transform: translateY(-20px) translateX(10px) scale(1.1);
    opacity: 0.6;
  }
  50% {
    transform: translateY(-40px) translateX(-5px) scale(0.9);
    opacity: 0.4;
  }
  75% {
    transform: translateY(-20px) translateX(15px) scale(1.05);
    opacity: 0.5;
  }
}

🎯 Motion Decision Tree

Elemen apa yang dianimasikan?
│
├── Hover/Interaksi → dur-fast (150ms) + --ease-out
│   ├── Card → translateY(-2px) + shadow deepen
│   ├── Button → scale(1.02) / scale(0.98)
│   ├── Link → opacity/underline
│   └── Icon → rotate/color
│
├── Masuk ke halaman → dur-slow (350ms) + stagger
│   ├── Halaman baru → fade + slideY(12px)
│   ├── List items → stagger (40ms per item)
│   └── Modal → scale(0.92→1) + fade overlay
│
├── Keluar dari halaman → dur-normal (250ms) + ease-in-out
│   ├── Halaman → fade + slideY(-8px)
│   ├── Toast → slideX(100%)
│   └── Modal → scale(→0.95) + fade overlay
│
├── Loading → infinite loop
│   ├── Skeleton → shimmer 1.5s
│   ├── Spinner → spin
│   └── AI Analysis → glow-pulse 2s
│
└── State change → dur-normal (250ms) + --ease-out
    ├── Sidebar → width transition
    ├── Accordion → height transition
    └── Badge → scale(0.8→1)

Reduced Motion

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  /* But allow opacity transitions for basic UX */
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 0.15s ease !important;
  }
}

Programmatic Check

// hooks/useReducedMotion.ts
export function useReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mq.matches);

    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return reduced;
}
// Dalam komponen
<script setup>
const reducedMotion = useReducedMotion();
</script>

<template>
  <Transition :duration="reducedMotion ? 0 : 350">
    ...
  </Transition>
</template>

⚠️ Anti-Patterns Animasi

Durasi terlalu lama

/* ❌ JANGAN — 1 detik terasa lambat */
.sidebar { transition: width 1s ease; }

/* ✅ 250350ms adalah sweet spot UI */
.sidebar { transition: width var(--dur-slow) var(--ease-out-quint); }

Semua bergerak bersamaan (tanpa stagger)

/* ❌ JANGAN — tidak graceful */
.card { animation: fadeIn 0.3s ease; }

/* ✅ Stagger menciptakan gelombang natural */
.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 40ms; }

Easing yang salah untuk konteks

/* ❌ JANGAN — ease-in untuk enter terasa lambat di awal */
.modal { animation: scaleIn 0.3s ease-in; }

/* ✅ ease-out untuk enter — cepat mulai, soft berhenti */
.modal { animation: scaleIn 0.3s var(--ease-out-quint); }

Mengabaikan reduced motion

/* ❌ JANGAN — tidak accessible */
.particle { animation: float 8s infinite; }

/* ✅ Diberhentikan untuk reduced motion */
@media (prefers-reduced-motion: reduce) {
  .particle { display: none; }
}

🔗 Referensi

Sumber Konsep
Easing Functions Cheat Sheet Visualisasi easing curves
Material Motion Sistem motion Google
GSAP Production-grade animation library
AnimXYZ Utility-first CSS animations

"Gerak adalah bahasa ingatan yang tak terucap — setiap transisi adalah cerita." ❄️🩵