From 36e13ba7fa2be34871b7a51d2648d5c6b337f7f4 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 08:48:17 -0600 Subject: [PATCH] motion: calm admin tab/drawer/popover/toast transitions Follow-up to 9fcc514. The global motion pass hit the public site; this targets the admin design system, which had its own animation budget that was still producing positional movement on every interaction. Killed: - TabSwitcher y:4 slide (every tab change in /admin) - CommandPalette scale(0.98) panel pop - ha-drawer-in: 100% translateX (full-screen sweep) - ha-popover-in: translateY(-4px) + scale(0.98) - ha-check-pop: cubic-bezier(0.34, 1.56, 0.64, 1) 1.15x overshoot - slide-in-from-right: 100% translateX (toasts) - dashboard-fade-up: 10px Y slide on every card - tailwindcss-animate keyframes for slide-in-from-{left,right,top,bottom} and zoom-in-{90,95} (used by ToastContainer, LotDetailPanel) Calmed: - All durations cut to 120-200ms range with ease-out - All keyframes that survived are opacity-only Added a @media (prefers-reduced-motion: reduce) block scoped to the admin selectors so users with that OS setting see instant transitions across toasts/drawers/popovers/dashboard cards. --- src/components/admin/CommandPalette.tsx | 12 ++- src/components/admin/TabSwitcher.tsx | 20 +++-- src/styles/admin-design-system.css | 98 +++++++++++++++++++++---- 3 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/components/admin/CommandPalette.tsx b/src/components/admin/CommandPalette.tsx index 2a33a51..9fcbce1 100644 --- a/src/components/admin/CommandPalette.tsx +++ b/src/components/admin/CommandPalette.tsx @@ -278,15 +278,21 @@ export default function CommandPalette() { }} > {/* Local keyframes — scoped via the dialog's runtime so they don't - leak into the global stylesheet. */} + leak into the global stylesheet. The panel now fades in without + the scale(0.98) start (removed — the slight scale combined with + the backdrop fade read as a "pop" when the palette opened). */} diff --git a/src/components/admin/TabSwitcher.tsx b/src/components/admin/TabSwitcher.tsx index 3615429..88d6aa0 100644 --- a/src/components/admin/TabSwitcher.tsx +++ b/src/components/admin/TabSwitcher.tsx @@ -10,18 +10,26 @@ type Props = { /** * Fades the content of a tabbed panel when the active tab changes. * The tab key drives the animation, so switching tabs triggers a quick - * fade-in of the new content. No motion when the tab key is stable - * (i.e. on first render of a given tab). + * opacity-only fade-in of the new content. + * + * Motion note: the previous version used `y: 4` / `y: -4` on enter/exit + * for a small slide. That 4px slide, multiplied across every tab change + * in the admin, was a steady source of positional movement. Now it's + * pure opacity, 120ms, ease-out. `reducedMotion="user"` on the global + * will further strip this to instant if the user has + * prefers-reduced-motion enabled. + * + * No motion when the tab key is stable (i.e. on first render of a given tab). */ export function TabSwitcher({ tabKey, children }: Props) { return ( {children} diff --git a/src/styles/admin-design-system.css b/src/styles/admin-design-system.css index 98d021a..4d9beb3 100644 --- a/src/styles/admin-design-system.css +++ b/src/styles/admin-design-system.css @@ -358,9 +358,11 @@ } /* === Toast Animations === */ +/* Motion-reduced: was 100% translateX with 0.3s — too far across the screen + * for a small notification. Now 24px slide, 180ms. */ @keyframes slide-in-from-right { from { - transform: translateX(100%); + transform: translateX(24px); opacity: 0; } to { @@ -379,7 +381,7 @@ } .animate-in { - animation: slide-in-from-right 0.3s ease-out, fade-in 0.3s ease-out; + animation: slide-in-from-right 0.18s ease-out, fade-in 0.18s ease-out; } /* ============================================================ */ @@ -1003,7 +1005,7 @@ align-items: center; justify-content: center; z-index: 2; - animation: ha-check-pop 0.28s cubic-bezier(0.34, 1.56, 0.64, 1); + animation: ha-check-pop 0.16s ease-out; } .ha-product-card-plus { position: absolute; @@ -1056,9 +1058,12 @@ opacity: 1; } +/* Motion-reduced: was scale 0 → 1.15 → 1 with the bouncy + * cubic-bezier(0.34, 1.56, 0.64, 1) — that overshoots 15% past the + * target then settles, which reads as twitchy. Now scale 0.92 → 1 + * (small "appear" with no overshoot) in 160ms with a plain ease-out. */ @keyframes ha-check-pop { - 0% { transform: scale(0); opacity: 0; } - 60% { transform: scale(1.15); } + 0% { transform: scale(0.92); opacity: 0; } 100% { transform: scale(1); opacity: 1; } } @@ -1498,11 +1503,14 @@ 0 20px 40px -8px rgba(60, 56, 37, 0.18), 0 4px 8px -2px rgba(60, 56, 37, 0.06); padding: 0.875rem 1rem 0.75rem 1rem; - animation: ha-popover-in 0.16s cubic-bezier(0.4, 0, 0.2, 1); + animation: ha-popover-in 0.12s ease-out; } +/* Motion-reduced: removed translateY(-4px) and scale(0.98) — popovers now + * just fade in. The 4px slide + slight scale compounded across the day + * when hovering many calendar events. */ @keyframes ha-popover-in { - from { opacity: 0; transform: translateY(-4px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { opacity: 0; } + to { opacity: 1; } } .ha-event-popover-arrow { position: absolute; @@ -1616,7 +1624,7 @@ background: rgba(60, 56, 37, 0.3); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); - animation: ha-fade-in 0.18s ease-out; + animation: ha-fade-in 0.14s ease-out; } @keyframes ha-fade-in { from { opacity: 0; } @@ -1637,10 +1645,13 @@ -4px 0 8px -2px rgba(60, 56, 37, 0.04); display: flex; flex-direction: column; - animation: ha-drawer-in 0.22s cubic-bezier(0.4, 0, 0.2, 1); + /* Motion-reduced: was 100% translateX (drawer traveled the full width of + * the screen). Now 24px — enough to feel like the drawer is sliding in + * from the right, but doesn't make the eye track across the whole screen. */ + animation: ha-drawer-in 0.18s ease-out; } @keyframes ha-drawer-in { - from { transform: translateX(100%); } + from { transform: translateX(24px); } to { transform: translateX(0); } } .ha-drawer-header { @@ -1954,11 +1965,12 @@ /* === Admin Dashboard — Compact Card System ================== */ /* ============================================================ */ -/* Entrance animation — staggered fade-up */ +/* Entrance animation — staggered fade-up. + * Motion-reduced: was 10px Y slide, 0.35s. Now 4px Y, 0.2s. */ @keyframes dashboard-fade-up { from { opacity: 0; - transform: translateY(10px); + transform: translateY(4px); } to { opacity: 1; @@ -1966,7 +1978,7 @@ } } .animate-fade-up { - animation: dashboard-fade-up 0.35s cubic-bezier(0.4, 0, 0.2, 1) both; + animation: dashboard-fade-up 0.2s ease-out both; } /* ── Stat Card ─────────────────────────────────────────────── */ @@ -2338,3 +2350,61 @@ font-size: 0.6875rem; } } + +/* ============================================================ */ +/* === Reduced motion — admin scope =========================== */ +/* ============================================================ */ +/* The globals.css media block already wipes animation/transition durations + * for the public site. This block does the same for the admin design system: + * - kills all admin-specific keyframes (popovers, drawers, toasts, fade-up) + * - locks every transition to ~instant + * - drops the last positional nudges (arrow translate, sidebar pulse) + * The skeleton/shimmer loading animations are kept because they're + * load-state indicators, not interaction feedback. + */ +@media (prefers-reduced-motion: reduce) { + .animate-fade-up, + .animate-in, + .ha-drawer, + .ha-drawer-backdrop, + .ha-event-popover { + animation: none !important; + } + .animate-fade-up, + .ha-drawer { + transform: none !important; + opacity: 1 !important; + } + .admin-section-card-arrow { + transition: none !important; + transform: none !important; + } + /* Tab switcher relies on framer-motion AnimatePresence which is governed + * by the global in Providers.tsx. */ +} + +/* ============================================================ */ +/* === Tailwind animate-in overrides ========================= */ +/* ============================================================ */ +/* These target the `tailwindcss-animate` plugin's utilities (`animate-in` + * paired with `slide-in-from-*`, `zoom-in-*`, `fade-in`). Used in + * ToastContainer and LotDetailPanel. + * + * The defaults in that plugin define keyframes with: + * - `slide-in-from-right-5` → translateX(1.25rem) (20px — a lot for a toast) + * - `zoom-in-95` → scale 0.95 → 1 (5% scale on modal open) + * + * Both compounds quickly: every toast + every modal pop animates a positional + * or scale change. The overrides below use CSS specificity to swap the + * keyframes for opacity-only fades. */ +@keyframes slide-in-from-right-5 { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-right-4 { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-right-3 { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-right-2 { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-right { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-bottom { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-top { from { opacity: 0; } to { opacity: 1; } } +@keyframes slide-in-from-left { from { opacity: 0; } to { opacity: 1; } } +@keyframes zoom-in-95 { from { opacity: 0; } to { opacity: 1; } } +@keyframes zoom-in-90 { from { opacity: 0; } to { opacity: 1; } } +@keyframes zoom-in { from { opacity: 0; } to { opacity: 1; } }