Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/web/src/components/Navbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
39 changes: 35 additions & 4 deletions apps/web/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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<IconState>('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 (
<nav className="navbar glass" id="main-nav">
Expand All @@ -13,14 +35,23 @@ export default function Navbar() {
<span className="gradient-text">DevCard</span>
</Link>
<button
className="theme-toggle"
onClick={toggleTheme}
className={`theme-toggle${glowing ? ' glow' : ''}`}
onClick={handleToggle}
onAnimationEnd={(e) => {
if (e.animationName === 'toggle-glow') setGlowing(false);
}}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
id="theme-toggle-btn"
>
{theme === 'dark' ? '☀️' : '🌙'}
<span
className={`theme-toggle-icon ${iconState !== 'idle' ? iconState : ''}`}
onAnimationEnd={handleAnimationEnd}
aria-hidden="true"
>
{displayedTheme === 'dark' ? '☀️' : '🌙'}
</span>
</button>
</div>
</nav>
);
}
}
35 changes: 34 additions & 1 deletion apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/lib/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@
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 (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
Expand All @@ -40,7 +47,7 @@
);
}

export function useTheme(): ThemeContextValue {

Check failure on line 50 in apps/web/src/lib/theme.tsx

View workflow job for this annotation

GitHub Actions / web-ci

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
Expand Down
Loading