Skip to content

Commit 1b8179d

Browse files
feat(web): add smooth theme transition animation (#536)
* fix: improve feature card readability and contrast (#319) * feat(web): add smooth theme transition animation
1 parent 92219c7 commit 1b8179d

4 files changed

Lines changed: 105 additions & 7 deletions

File tree

apps/web/src/components/Navbar.css

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
align-items: center;
3535
justify-content: center;
3636
font-size: 1.25rem;
37-
transition: transform 0.24s ease, background-color 0.24s ease, border-color 0.24s ease;
37+
transition: transform 0.24s ease, background-color 0.24s ease, border-color 0.24s ease;
38+
overflow: hidden;
39+
position: relative;
3840
}
3941

4042
.theme-toggle:hover {
@@ -43,11 +45,36 @@
4345
border-color: rgba(99, 102, 241, 0.3);
4446
}
4547

48+
.theme-toggle:active {
49+
transform: scale(0.92);
50+
background: rgba(99, 102, 241, 0.15);
51+
border-color: rgba(99, 102, 241, 0.5);
52+
}
53+
54+
.theme-toggle.glow {
55+
animation: toggle-glow 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
56+
}
57+
4658
.theme-toggle:focus-visible {
4759
outline: 3px solid rgba(99, 102, 241, 0.24);
4860
outline-offset: 3px;
4961
}
5062

63+
.theme-toggle-icon {
64+
display: inline-flex;
65+
align-items: center;
66+
justify-content: center;
67+
line-height: 1;
68+
}
69+
70+
.theme-toggle-icon.hiding {
71+
animation: icon-hide 0.2s cubic-bezier(0.4, 0, 1, 1) forwards;
72+
}
73+
74+
.theme-toggle-icon.showing {
75+
animation: icon-show 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
76+
}
77+
5178
@media (max-width: 860px) {
5279
.navbar {
5380
margin-top: 0.9rem;

apps/web/src/components/Navbar.tsx

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1+
import { useState } from 'react';
12
import { Link } from 'react-router-dom';
23
import { useTheme } from '../lib/theme';
34
import './Navbar.css';
45

6+
type IconState = 'idle' | 'hiding' | 'showing';
7+
58
export default function Navbar() {
69
const { theme, toggleTheme } = useTheme();
10+
const [iconState, setIconState] = useState<IconState>('idle');
11+
const [displayedTheme, setDisplayedTheme] = useState(theme);
12+
const [glowing, setGlowing] = useState(false);
13+
14+
const handleToggle = () => {
15+
if (iconState !== 'idle') return;
16+
setIconState('hiding');
17+
setGlowing(true);
18+
};
19+
20+
const handleAnimationEnd = () => {
21+
if (iconState === 'hiding') {
22+
toggleTheme();
23+
setDisplayedTheme((t) => (t === 'dark' ? 'light' : 'dark'));
24+
setIconState('showing');
25+
} else if (iconState === 'showing') {
26+
setIconState('idle');
27+
}
28+
};
729

830
return (
931
<nav className="navbar glass" id="main-nav">
@@ -13,14 +35,23 @@ export default function Navbar() {
1335
<span className="gradient-text">DevCard</span>
1436
</Link>
1537
<button
16-
className="theme-toggle"
17-
onClick={toggleTheme}
38+
className={`theme-toggle${glowing ? ' glow' : ''}`}
39+
onClick={handleToggle}
40+
onAnimationEnd={(e) => {
41+
if (e.animationName === 'toggle-glow') setGlowing(false);
42+
}}
1843
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
1944
id="theme-toggle-btn"
2045
>
21-
{theme === 'dark' ? '☀️' : '🌙'}
46+
<span
47+
className={`theme-toggle-icon ${iconState !== 'idle' ? iconState : ''}`}
48+
onAnimationEnd={handleAnimationEnd}
49+
aria-hidden="true"
50+
>
51+
{displayedTheme === 'dark' ? '☀️' : '🌙'}
52+
</span>
2253
</button>
2354
</div>
2455
</nav>
2556
);
26-
}
57+
}

apps/web/src/index.css

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,36 @@ html.dark {
5656
box-sizing: border-box;
5757
}
5858

59+
60+
html.theme-transitioning *,
61+
html.theme-transitioning *::before,
62+
html.theme-transitioning *::after {
63+
transition:
64+
background-color 0.35s cubic-bezier(0.4, 0, 0.2, 1),
65+
background 0.35s cubic-bezier(0.4, 0, 0.2, 1),
66+
color 0.35s cubic-bezier(0.4, 0, 0.2, 1),
67+
border-color 0.35s cubic-bezier(0.4, 0, 0.2, 1),
68+
box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1),
69+
fill 0.35s cubic-bezier(0.4, 0, 0.2, 1),
70+
stroke 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important;
71+
}
72+
73+
74+
@keyframes icon-hide {
75+
0% { transform: scale(1) rotate(0deg); opacity: 1; }
76+
100% { transform: scale(0) rotate(-90deg); opacity: 0; }
77+
}
78+
79+
@keyframes icon-show {
80+
0% { transform: scale(0) rotate(90deg); opacity: 0; }
81+
100% { transform: scale(1) rotate(0deg); opacity: 1; }
82+
}
83+
@keyframes toggle-glow {
84+
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.55); }
85+
50% { box-shadow: 0 0 0 8px rgba(99, 102, 241, 0.18); }
86+
100% { box-shadow: 0 0 0 14px rgba(99, 102, 241, 0); }
87+
}
88+
5989
body {
6090
font-family: 'Inter', sans-serif;
6191
background:
@@ -191,7 +221,10 @@ html {
191221
}
192222

193223
@media (prefers-reduced-motion: reduce) {
194-
* {
224+
*,
225+
html.theme-transitioning *,
226+
html.theme-transitioning *::before,
227+
html.theme-transitioning *::after {
195228
animation-duration: 0.01ms !important;
196229
animation-iteration-count: 1 !important;
197230
transition-duration: 0.01ms !important;

apps/web/src/lib/theme.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
3131
localStorage.setItem('devcard-theme', theme);
3232
}, [theme]);
3333

34-
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
34+
const toggleTheme = () => {
35+
const root = document.documentElement;
36+
root.classList.add('theme-transitioning');
37+
const cleanup = () => root.classList.remove('theme-transitioning');
38+
window.setTimeout(cleanup, 400);
39+
root.addEventListener('transitionend', cleanup, { once: true });
40+
setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
41+
};
3542

3643
return (
3744
<ThemeContext.Provider value={{ theme, toggleTheme }}>

0 commit comments

Comments
 (0)