From 9a0821df00380de2d7de7bf138afac527e382c43 Mon Sep 17 00:00:00 2001 From: jakebodea Date: Sat, 7 Feb 2026 19:08:10 -0800 Subject: [PATCH] Add theme dropdown with animated icon morphing Replace the simple dark/light toggle button with a dropdown offering dark, light, and system theme options. Icons morph smoothly via Framer Motion on theme change. The `t` shortcut remains as a dark/light toggle. Closes #10 Co-Authored-By: Claude Opus 4.6 --- app/layout.tsx | 2 +- components/layout/theme-toggle.tsx | 140 +++++++++++++++++++++++++++++ components/layout/top-nav.tsx | 48 ++-------- 3 files changed, 149 insertions(+), 41 deletions(-) create mode 100644 components/layout/theme-toggle.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 6d97745..7d26443 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -77,7 +77,7 @@ export default function RootLayout({ diff --git a/components/layout/theme-toggle.tsx b/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..44832ea --- /dev/null +++ b/components/layout/theme-toggle.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import { useTheme } from "next-themes" +import { AnimatePresence, motion } from "framer-motion" +import { Moon, Sun, Monitor } from "lucide-react" + +const iconSizeClasses = { + sm: "h-4 w-4", + md: "h-5 w-5", +} + +const themeOptions = [ + { value: "dark", label: "dark", icon: Moon }, + { value: "light", label: "light", icon: Sun }, + { value: "system", label: "system", icon: Monitor }, +] as const + +const iconTransition = { + duration: 0.15, +} + +const dropdownTransition = { + type: "spring" as const, + stiffness: 300, + damping: 20, +} + +function ThemeIcon({ theme, className }: { theme: string; className: string }) { + const option = themeOptions.find((o) => o.value === theme) + const Icon = option?.icon ?? Moon + return +} + +export function ThemeToggle({ iconSize = "sm" }: { iconSize?: "sm" | "md" }) { + const { theme, resolvedTheme, setTheme } = useTheme() + const [isOpen, setIsOpen] = React.useState(false) + const [mounted, setMounted] = React.useState(false) + const containerRef = React.useRef(null) + + React.useEffect(() => { + setMounted(true) + }, []) + + // Click-outside dismissal + React.useEffect(() => { + if (!isOpen) return + + const handleMouseDown = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + + document.addEventListener("mousedown", handleMouseDown) + return () => document.removeEventListener("mousedown", handleMouseDown) + }, [isOpen]) + + // Keyboard handling + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return + + if (e.key === "t") { + setTheme(resolvedTheme === "dark" ? "light" : "dark") + setIsOpen(false) + } + + if (e.key === "Escape") { + setIsOpen(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [resolvedTheme, setTheme]) + + if (!mounted) return null + + const sizeClass = iconSizeClasses[iconSize] + + return ( +
+ + + + {isOpen && ( + + {themeOptions.map((option) => { + const isActive = theme === option.value + return ( + + ) + })} + + )} + +
+ ) +} diff --git a/components/layout/top-nav.tsx b/components/layout/top-nav.tsx index b64bd9a..4109ec9 100644 --- a/components/layout/top-nav.tsx +++ b/components/layout/top-nav.tsx @@ -3,14 +3,13 @@ import * as React from "react" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" -import { useTheme } from "next-themes" import { motion } from "framer-motion" import { cn } from "@/lib/utils" -import { Sun, Moon } from "lucide-react" import { MenuIcon } from "@/components/ui/menu-icon" import { TooltipProvider } from "@/components/ui/tooltip" import { ShortcutTooltip } from "@/components/common/shortcut-tooltip" import { MobileMenu } from "@/components/layout/mobile-menu" +import { ThemeToggle } from "@/components/layout/theme-toggle" const navItems = [ { title: "home", href: "/" }, @@ -23,14 +22,8 @@ const navItems = [ export function TopNav() { const pathname = usePathname() const router = useRouter() - const { setTheme, resolvedTheme } = useTheme() - const [mounted, setMounted] = React.useState(false) const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false) - React.useEffect(() => { - setMounted(true) - }, []) - React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement @@ -40,14 +33,11 @@ export function TopNav() { if (num >= 1 && num <= navItems.length) { router.push(navItems[num - 1].href) } - if (e.key === "t") { - setTheme(resolvedTheme === "dark" ? "light" : "dark") - } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [resolvedTheme, setTheme, router]) + }, [router]) const isActive = (href: string) => { if (href === "/") return pathname === "/" @@ -94,21 +84,11 @@ export function TopNav() { ))} {/* Desktop Theme toggle */} - {mounted && ( - - - - )} + +
+ +
+
@@ -116,19 +96,7 @@ export function TopNav() {
{/* Mobile Theme toggle */} - {mounted && ( - - )} + {/* Hamburger button */}