motion: calm admin tab/drawer/popover/toast transitions
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m15s
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.
This commit is contained in:
@@ -278,15 +278,21 @@ export default function CommandPalette() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Local keyframes — scoped via the dialog's runtime so they don't
|
{/* 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). */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes cp-fade-in {
|
@keyframes cp-fade-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
@keyframes cp-scale-in {
|
@keyframes cp-scale-in {
|
||||||
from { opacity: 0; transform: scale(0.98); }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
@keyframes cp-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes cp-scale-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,26 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* Fades the content of a tabbed panel when the active tab changes.
|
* Fades the content of a tabbed panel when the active tab changes.
|
||||||
* The tab key drives the animation, so switching tabs triggers a quick
|
* 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
|
* opacity-only fade-in of the new content.
|
||||||
* (i.e. on first render of a given tab).
|
*
|
||||||
|
* 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
|
||||||
|
* <MotionConfig> 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) {
|
export function TabSwitcher({ tabKey, children }: Props) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={tabKey}
|
key={tabKey}
|
||||||
initial={{ opacity: 0, y: 4 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, y: -4 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -358,9 +358,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* === Toast Animations === */
|
/* === 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 {
|
@keyframes slide-in-from-right {
|
||||||
from {
|
from {
|
||||||
transform: translateX(100%);
|
transform: translateX(24px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
@@ -379,7 +381,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animate-in {
|
.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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 2;
|
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 {
|
.ha-product-card-plus {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1056,9 +1058,12 @@
|
|||||||
opacity: 1;
|
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 {
|
@keyframes ha-check-pop {
|
||||||
0% { transform: scale(0); opacity: 0; }
|
0% { transform: scale(0.92); opacity: 0; }
|
||||||
60% { transform: scale(1.15); }
|
|
||||||
100% { transform: scale(1); opacity: 1; }
|
100% { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1498,11 +1503,14 @@
|
|||||||
0 20px 40px -8px rgba(60, 56, 37, 0.18),
|
0 20px 40px -8px rgba(60, 56, 37, 0.18),
|
||||||
0 4px 8px -2px rgba(60, 56, 37, 0.06);
|
0 4px 8px -2px rgba(60, 56, 37, 0.06);
|
||||||
padding: 0.875rem 1rem 0.75rem 1rem;
|
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 {
|
@keyframes ha-popover-in {
|
||||||
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
.ha-event-popover-arrow {
|
.ha-event-popover-arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1616,7 +1624,7 @@
|
|||||||
background: rgba(60, 56, 37, 0.3);
|
background: rgba(60, 56, 37, 0.3);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
-webkit-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 {
|
@keyframes ha-fade-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
@@ -1637,10 +1645,13 @@
|
|||||||
-4px 0 8px -2px rgba(60, 56, 37, 0.04);
|
-4px 0 8px -2px rgba(60, 56, 37, 0.04);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
@keyframes ha-drawer-in {
|
||||||
from { transform: translateX(100%); }
|
from { transform: translateX(24px); }
|
||||||
to { transform: translateX(0); }
|
to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
.ha-drawer-header {
|
.ha-drawer-header {
|
||||||
@@ -1954,11 +1965,12 @@
|
|||||||
/* === Admin Dashboard — Compact Card System ================== */
|
/* === 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 {
|
@keyframes dashboard-fade-up {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(4px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -1966,7 +1978,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animate-fade-up {
|
.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 ─────────────────────────────────────────────── */
|
/* ── Stat Card ─────────────────────────────────────────────── */
|
||||||
@@ -2338,3 +2350,61 @@
|
|||||||
font-size: 0.6875rem;
|
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 <MotionConfig reducedMotion="user"> 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; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user