diff --git a/apps/web/src/components/Navbar.css b/apps/web/src/components/Navbar.css index 4e66a2f2..95a33fb0 100644 --- a/apps/web/src/components/Navbar.css +++ b/apps/web/src/components/Navbar.css @@ -34,7 +34,9 @@ align-items: center; justify-content: center; font-size: 1.25rem; - transition: transform 0.24s ease, background-color 0.24s ease, border-color 0.24s ease; + transition: transform 0.24s ease, background-color 0.24s ease, border-color 0.24s ease; + overflow: hidden; + position: relative; } .theme-toggle:hover { @@ -43,11 +45,36 @@ border-color: rgba(99, 102, 241, 0.3); } +.theme-toggle:active { + transform: scale(0.92); + background: rgba(99, 102, 241, 0.15); + border-color: rgba(99, 102, 241, 0.5); +} + +.theme-toggle.glow { + animation: toggle-glow 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + .theme-toggle:focus-visible { outline: 3px solid rgba(99, 102, 241, 0.24); outline-offset: 3px; } +.theme-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.theme-toggle-icon.hiding { + animation: icon-hide 0.2s cubic-bezier(0.4, 0, 1, 1) forwards; +} + +.theme-toggle-icon.showing { + animation: icon-show 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + @media (max-width: 860px) { .navbar { margin-top: 0.9rem; diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx index debd63c7..7d4c5856 100644 --- a/apps/web/src/components/Navbar.tsx +++ b/apps/web/src/components/Navbar.tsx @@ -1,9 +1,31 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useTheme } from '../lib/theme'; import './Navbar.css'; +type IconState = 'idle' | 'hiding' | 'showing'; + export default function Navbar() { const { theme, toggleTheme } = useTheme(); + const [iconState, setIconState] = useState('idle'); + const [displayedTheme, setDisplayedTheme] = useState(theme); + const [glowing, setGlowing] = useState(false); + + const handleToggle = () => { + if (iconState !== 'idle') return; + setIconState('hiding'); + setGlowing(true); + }; + + const handleAnimationEnd = () => { + if (iconState === 'hiding') { + toggleTheme(); + setDisplayedTheme((t) => (t === 'dark' ? 'light' : 'dark')); + setIconState('showing'); + } else if (iconState === 'showing') { + setIconState('idle'); + } + }; return ( ); -} +} \ No newline at end of file diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e8cde905..e0b65de7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -56,6 +56,36 @@ html.dark { box-sizing: border-box; } + +html.theme-transitioning *, +html.theme-transitioning *::before, +html.theme-transitioning *::after { + transition: + background-color 0.35s cubic-bezier(0.4, 0, 0.2, 1), + background 0.35s cubic-bezier(0.4, 0, 0.2, 1), + color 0.35s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.35s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1), + fill 0.35s cubic-bezier(0.4, 0, 0.2, 1), + stroke 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + + +@keyframes icon-hide { + 0% { transform: scale(1) rotate(0deg); opacity: 1; } + 100% { transform: scale(0) rotate(-90deg); opacity: 0; } +} + +@keyframes icon-show { + 0% { transform: scale(0) rotate(90deg); opacity: 0; } + 100% { transform: scale(1) rotate(0deg); opacity: 1; } +} +@keyframes toggle-glow { + 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.55); } + 50% { box-shadow: 0 0 0 8px rgba(99, 102, 241, 0.18); } + 100% { box-shadow: 0 0 0 14px rgba(99, 102, 241, 0); } +} + body { font-family: 'Inter', sans-serif; background: @@ -191,7 +221,10 @@ html { } @media (prefers-reduced-motion: reduce) { - * { + *, + html.theme-transitioning *, + html.theme-transitioning *::before, + html.theme-transitioning *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx index 7beda8bd..095e3507 100644 --- a/apps/web/src/lib/theme.tsx +++ b/apps/web/src/lib/theme.tsx @@ -31,7 +31,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) { localStorage.setItem('devcard-theme', theme); }, [theme]); - const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')); +const toggleTheme = () => { + const root = document.documentElement; + root.classList.add('theme-transitioning'); + const cleanup = () => root.classList.remove('theme-transitioning'); + window.setTimeout(cleanup, 400); + root.addEventListener('transitionend', cleanup, { once: true }); + setTheme((t) => (t === 'dark' ? 'light' : 'dark')); +}; return (