(
({ className, type, ...props }, ref) => {
diff --git a/src/shared/components/theme-toggle.tsx b/src/shared/components/theme-toggle.tsx
index 5a1dcbc..9a5b93f 100644
--- a/src/shared/components/theme-toggle.tsx
+++ b/src/shared/components/theme-toggle.tsx
@@ -1,18 +1,14 @@
"use client"
-import { useEffect, useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/shared/components/button"
+import { useHasMounted } from "@/shared/hooks/use-has-mounted"
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme()
- const [mounted, setMounted] = useState(false)
-
- useEffect(() => {
- setMounted(true)
- }, [])
+ const mounted = useHasMounted()
const handleToggle = () => {
const currentTheme = theme === "system" ? resolvedTheme : theme
diff --git a/src/shared/components/ui/heatmap-background.tsx b/src/shared/components/ui/heatmap-background.tsx
index 546aa18..f68df83 100644
--- a/src/shared/components/ui/heatmap-background.tsx
+++ b/src/shared/components/ui/heatmap-background.tsx
@@ -1,35 +1,84 @@
"use client";
-import { useEffect, useState } from "react";
+import { useMemo, useSyncExternalStore } from "react";
import { motion } from "framer-motion";
import { cn } from "@/shared/lib/utils";
+type HeatmapBlock = {
+ colorClass: string
+ delay: number
+ duration: number
+ opacity: number
+}
+
+function subscribeToWindowResize(onStoreChange: () => void) {
+ if (typeof window === "undefined") {
+ return () => {}
+ }
+
+ let frameId: number | null = null
+ const throttledHandler = () => {
+ if (frameId !== null) {
+ return
+ }
+
+ frameId = window.requestAnimationFrame(() => {
+ frameId = null
+ onStoreChange()
+ })
+ }
+
+ window.addEventListener("resize", throttledHandler)
+ return () => {
+ window.removeEventListener("resize", throttledHandler)
+
+ if (frameId !== null) {
+ window.cancelAnimationFrame(frameId)
+ }
+ }
+}
+
+function getWindowWidth(): number {
+ if (typeof window === "undefined") {
+ return 1200
+ }
+
+ return window.innerWidth
+}
+
+function seededValue(seed: number): number {
+ const normalized = Math.sin(seed) * 10000
+ return normalized - Math.floor(normalized)
+}
+
+function buildBlocks(width: number): HeatmapBlock[] {
+ const blockSize = width < 768 ? 30 : 40
+ const cols = Math.ceil(width / blockSize)
+ const rows = 12
+ const total = cols * rows
+
+ return Array.from({ length: total }, (_, index) => {
+ const activeSeed = seededValue(index + width)
+ const opacitySeed = seededValue(index * 1.7 + width)
+ const durationSeed = seededValue(index * 2.3 + width)
+ const delaySeed = seededValue(index * 3.1 + width)
+ const colorSeed = seededValue(index * 4.9 + width)
+ const isActive = activeSeed > 0.8
+
+ return {
+ opacity: isActive ? opacitySeed * 0.35 + 0.15 : 0.03,
+ duration: durationSeed * 2 + 2,
+ delay: delaySeed * 5,
+ colorClass: colorSeed > 0.6
+ ? "bg-primary dark:bg-blue-500"
+ : "bg-emerald-500 dark:bg-emerald-400",
+ }
+ })
+}
+
export function HeatmapBackground() {
- const [blocks, setBlocks] = useState<{ opacity: number; colorClass: string }[]>([]);
-
- useEffect(() => {
- const width = typeof window !== "undefined" ? window.innerWidth : 1200;
- const blockSize = width < 768 ? 30 : 40;
- const cols = Math.ceil(width / blockSize);
- const rows = 12;
- const total = cols * rows;
-
- const newBlocks = Array.from({ length: total }).map(() => {
- const isActive = Math.random() > 0.8; // 20% 활성 확률 유지
- return {
- // Opacity 범위: 0.15 ~ 0.5 (이 값만으로 투명도 조절)
- opacity: isActive ? Math.random() * 0.35 + 0.15 : 0.03,
-
- // [Fix] 다크 모드 클래스에서 불투명도(/40 등) 제거 및 더 밝은 컬러(400~500) 사용
- // 이제 Framer Motion의 opacity 값(최소 0.15)이 그대로 적용되어 훨씬 잘 보입니다.
- colorClass: Math.random() > 0.6
- ? "bg-primary dark:bg-blue-500"
- : "bg-emerald-500 dark:bg-emerald-400",
- };
- });
-
- setBlocks(newBlocks);
- }, []);
+ const width = useSyncExternalStore(subscribeToWindowResize, getWindowWidth, () => 1200)
+ const blocks = useMemo(() => buildBlocks(width), [width])
return (
@@ -43,10 +92,10 @@ export function HeatmapBackground() {
initial={{ opacity: 0 }}
animate={{ opacity: block.opacity }}
transition={{
- duration: Math.random() * 2 + 2,
+ duration: block.duration,
repeat: Infinity,
repeatType: "reverse",
- delay: Math.random() * 5,
+ delay: block.delay,
ease: "easeInOut"
}}
className={cn(
diff --git a/src/shared/hooks/use-has-mounted.ts b/src/shared/hooks/use-has-mounted.ts
new file mode 100644
index 0000000..1855276
--- /dev/null
+++ b/src/shared/hooks/use-has-mounted.ts
@@ -0,0 +1,9 @@
+"use client"
+
+import { useSyncExternalStore } from "react"
+
+const emptySubscribe = () => () => {}
+
+export function useHasMounted(): boolean {
+ return useSyncExternalStore(emptySubscribe, () => true, () => false)
+}
diff --git a/src/shared/hooks/use-media-query.ts b/src/shared/hooks/use-media-query.ts
index 652cbc0..d6be3ea 100644
--- a/src/shared/hooks/use-media-query.ts
+++ b/src/shared/hooks/use-media-query.ts
@@ -1,29 +1,31 @@
"use client"
-import { useState, useEffect } from "react"
+import { useCallback, useSyncExternalStore } from "react"
-export function useMediaQuery(query: string): boolean {
- const [matches, setMatches] = useState(false)
-
- useEffect(() => {
- const media = window.matchMedia(query)
+function getMediaQuerySnapshot(query: string): boolean {
+ if (typeof window === "undefined") {
+ return false
+ }
- // Set initial value
- setMatches(media.matches)
+ return window.matchMedia(query).matches
+}
- // Create listener
- const listener = (event: MediaQueryListEvent) => {
- setMatches(event.matches)
+export function useMediaQuery(query: string): boolean {
+ const subscribe = useCallback((onStoreChange: () => void) => {
+ if (typeof window === "undefined") {
+ return () => {}
}
- // Add listener
- media.addEventListener("change", listener)
+ const media = window.matchMedia(query)
+ const listener = () => onStoreChange()
- // Cleanup
+ media.addEventListener("change", listener)
return () => media.removeEventListener("change", listener)
}, [query])
- return matches
+ const getSnapshot = useCallback(() => getMediaQuerySnapshot(query), [query])
+
+ return useSyncExternalStore(subscribe, getSnapshot, () => false)
}
// Convenience hook for mobile detection
diff --git a/src/shared/hooks/use-reduced-motion.ts b/src/shared/hooks/use-reduced-motion.ts
index f0e10d9..f018d14 100644
--- a/src/shared/hooks/use-reduced-motion.ts
+++ b/src/shared/hooks/use-reduced-motion.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useMediaQuery } from './use-media-query'
/**
* Hook to detect if the user prefers reduced motion.
@@ -7,28 +7,5 @@ import { useState, useEffect } from 'react'
* This should be used to disable or simplify animations for accessibility.
*/
export function useReducedMotion(): boolean {
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
-
- useEffect(() => {
- // Check if window is available (SSR guard)
- if (typeof window === 'undefined') return
-
- const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
-
- // Set initial value
- setPrefersReducedMotion(mediaQuery.matches)
-
- // Listen for changes
- const handleChange = (event: MediaQueryListEvent) => {
- setPrefersReducedMotion(event.matches)
- }
-
- mediaQuery.addEventListener('change', handleChange)
-
- return () => {
- mediaQuery.removeEventListener('change', handleChange)
- }
- }, [])
-
- return prefersReducedMotion
+ return useMediaQuery('(prefers-reduced-motion: reduce)')
}
diff --git a/src/shared/hooks/use-throttle.ts b/src/shared/hooks/use-throttle.ts
index 874e08e..5a8b1d0 100644
--- a/src/shared/hooks/use-throttle.ts
+++ b/src/shared/hooks/use-throttle.ts
@@ -1,4 +1,4 @@
-import { useCallback, useRef } from 'react'
+import { useCallback, useEffect, useRef } from 'react'
/**
* Returns a throttled version of the callback that only fires at most once per `delay` ms.
@@ -7,31 +7,41 @@ import { useCallback, useRef } from 'react'
* @param callback - The function to throttle
* @param delay - Minimum time between calls in ms (default: 16ms = ~60fps)
*/
-export function useThrottledCallback void>(
- callback: T,
+export function useThrottledCallback(
+ callback: (...args: TArgs) => void,
delay: number = 16
-): T {
+): (...args: TArgs) => void {
const lastCallRef = useRef(0)
const rafRef = useRef(null)
+ const callbackRef = useRef(callback)
- return useCallback(
- ((...args: Parameters) => {
+ useEffect(() => {
+ callbackRef.current = callback
+ }, [callback])
+
+ useEffect(() => {
+ return () => {
+ if (rafRef.current !== null) {
+ cancelAnimationFrame(rafRef.current)
+ }
+ }
+ }, [])
+
+ return useCallback((...args: TArgs) => {
const now = performance.now()
if (now - lastCallRef.current >= delay) {
lastCallRef.current = now
- callback(...args)
+ callbackRef.current(...args)
} else if (!rafRef.current) {
// Schedule for next animation frame if we're throttling
rafRef.current = requestAnimationFrame(() => {
lastCallRef.current = performance.now()
rafRef.current = null
- callback(...args)
+ callbackRef.current(...args)
})
}
- }) as T,
- [callback, delay]
- )
+ }, [delay])
}
/**
diff --git a/src/shared/providers/locale-provider.tsx b/src/shared/providers/locale-provider.tsx
index 439cd2a..5a33527 100644
--- a/src/shared/providers/locale-provider.tsx
+++ b/src/shared/providers/locale-provider.tsx
@@ -38,14 +38,26 @@ type LocaleProviderProps = {
export function LocaleProvider({ children, initialLocale }: LocaleProviderProps) {
const pathname = usePathname();
- const [locale, setLocaleState] = useState(initialLocale);
+ const [localeState, setLocaleState] = useState(initialLocale);
+ const pathLocale = getLocaleFromPathname(pathname);
+ const locale = pathLocale ?? localeState;
useEffect(() => {
- const localeFromPath = getLocaleFromPathname(pathname);
- if (localeFromPath && localeFromPath !== locale) {
- setLocaleState(localeFromPath);
+ if (!pathLocale || pathLocale === localeState) {
+ return;
}
- }, [pathname, locale]);
+
+ let cancelled = false;
+ queueMicrotask(() => {
+ if (!cancelled) {
+ setLocaleState(pathLocale);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [pathLocale, localeState]);
useEffect(() => {
document.documentElement.lang = locale;
diff --git a/tailwind.config.ts b/tailwind.config.ts
index c8412a0..b48a06d 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
+import tailwindcssAnimate from "tailwindcss-animate";
const config: Config = {
darkMode: "class",
@@ -94,6 +95,6 @@ const config: Config = {
},
},
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [tailwindcssAnimate],
};
-export default config;
\ No newline at end of file
+export default config;