From b76e16d8bca0b022a9577403443a2c1bbcd10c3a Mon Sep 17 00:00:00 2001 From: Mohammed Rayan A Date: Thu, 16 Jul 2026 00:21:36 +0530 Subject: [PATCH] feat: animated theme toggle (MagicUI-style circle reveal) across all pages - Implement View Transitions API circle-reveal animation expanding from button click position - ThemeToggle 'icon' variant: animated Sun/Moon button with clip-path circle transition - ThemeToggle 'pill' variant: compact Sun/Moon picker for sidebar (no VT animation) - System mode tracked silently in ThemeContext; only light/dark shown in button - Add ::view-transition CSS to globals.css with vt-clip-reveal keyframe - Add animated ThemeToggle to landing page Hero navbar (home page) - Add animated ThemeToggle to LegalNav (privacy + terms pages) - Sidebar gets labelled 'Theme' row with pill toggle - Mobile header keeps animated icon toggle - Graceful fallback: instant toggle for reduced-motion / unsupported browsers --- app/components/Layout.tsx | 5 +- app/components/landing/Hero.tsx | 18 ++-- app/components/ui/LegalNav.tsx | 2 +- app/components/ui/ThemeToggle.tsx | 148 ++++++++++++++++++++---------- app/globals.css | 24 +++++ 5 files changed, 140 insertions(+), 57 deletions(-) diff --git a/app/components/Layout.tsx b/app/components/Layout.tsx index 3a1b374..83969b3 100644 --- a/app/components/Layout.tsx +++ b/app/components/Layout.tsx @@ -67,7 +67,10 @@ export function Layout({ children, currentPage, onNavigate }: LayoutProps) {
- +
+ Theme + +
) })} @@ -47,32 +100,31 @@ export function ThemeToggle({ variant = 'pill', className = '' }: ThemeTogglePro ) } - // Pill variant + // Default: animated icon button (Sun/Moon) with View Transition return ( -
- {options.map(({ mode, Icon, label }) => { - const active = themeMode === mode - return ( - - ) - })} -
+ {/* Sun icon — shown in light mode */} + + {/* Moon icon — shown in dark mode */} + + ) } diff --git a/app/globals.css b/app/globals.css index ae5823a..cd06e60 100644 --- a/app/globals.css +++ b/app/globals.css @@ -18,8 +18,32 @@ * { @apply border-slate-200 dark:border-slate-800; } + + /* ── View Transition: animated theme toggle ────────────────────────────── */ + /* Disable the default cross-fade so our clip-path runs cleanly */ + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; + } + + /* When the JS sets data-theme-vt="active", animate the new state */ + [data-theme-vt='active']::view-transition-new(root) { + clip-path: var(--vt-clip-to, circle(100% at 50% 50%)); + animation: vt-clip-reveal 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + @keyframes vt-clip-reveal { + from { + clip-path: var(--vt-clip-from, circle(0px at 50% 50%)); + } + to { + clip-path: var(--vt-clip-to, circle(100% at 50% 50%)); + } + } } + @layer components { .app-surface { @apply border border-slate-200/80 bg-white/90 shadow-sm shadow-slate-200/60 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/20;