diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts
index 0855431..d3500ed 100644
--- a/src/shared/constants/routes.ts
+++ b/src/shared/constants/routes.ts
@@ -6,5 +6,6 @@
*/
export const ROUTES = {
LANDING: '/',
+ LOGIN: '/login',
WRITING: '/writing',
} as const;
diff --git a/src/shared/styles/base/colors.css b/src/shared/styles/base/colors.css
index df8c0eb..d02c7a1 100644
--- a/src/shared/styles/base/colors.css
+++ b/src/shared/styles/base/colors.css
@@ -41,6 +41,8 @@
--color-white: #ffffff;
/* Shadow Tokens */
+ --shadow-header-login-button: 0 0 24px rgba(118, 186, 255, 0.28);
+ --shadow-header-login-button-hover: 0 8px 28px rgba(118, 186, 255, 0.36);
--shadow-tooltip-arrow: 0 0 16px rgba(118, 186, 255, 0.18);
--shadow-tooltip: 0 14px 44px rgba(0, 0, 0, 0.42), 0 0 0 1px rgba(118, 186, 255, 0.08);
}
diff --git a/src/shared/styles/base/z-index.css b/src/shared/styles/base/z-index.css
index 27edf16..eb603a4 100644
--- a/src/shared/styles/base/z-index.css
+++ b/src/shared/styles/base/z-index.css
@@ -5,6 +5,7 @@
예시: z-(--z-index-surface-content)
*/
--z-index-form-floating: 10;
+ --z-index-header: 30;
--z-index-surface-overlay: 40;
--z-index-surface-content: 50;
--z-index-tooltip: 60;
diff --git a/src/widgets/common/header/Header.stories.tsx b/src/widgets/common/header/Header.stories.tsx
new file mode 100644
index 0000000..cd2fda9
--- /dev/null
+++ b/src/widgets/common/header/Header.stories.tsx
@@ -0,0 +1,63 @@
+import { DefaultProfileImage } from '@/shared/assets/images';
+import { ROUTES } from '@/shared/constants/routes';
+
+import { Header } from './Header';
+
+import type { Meta, StoryObj } from '@storybook/nextjs';
+
+const meta = {
+ title: 'Widgets/Common/Header',
+ component: Header,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ Header Preview
+
+ 스크롤 시 Header 배경과 blur가 적용됩니다. Storybook에서는 Scrolled 스토리로 상태를
+ 고정해서 확인할 수 있습니다.
+
+
+
+
+ ),
+ ],
+ args: {
+ authenticatedLogoHref: ROUTES.WRITING,
+ loginHref: ROUTES.LOGIN,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+const loggedInUser = {
+ name: '이서정',
+ profileImageUrl: DefaultProfileImage.src,
+};
+
+export const Guest: Story = {};
+
+export const GuestScrolled: Story = {
+ args: {
+ isBackgroundBlurred: true,
+ },
+};
+
+export const LoggedIn: Story = {
+ args: {
+ user: loggedInUser,
+ },
+};
+
+export const LoggedInScrolled: Story = {
+ args: {
+ isBackgroundBlurred: true,
+ user: loggedInUser,
+ },
+};
diff --git a/src/widgets/common/header/Header.tsx b/src/widgets/common/header/Header.tsx
new file mode 100644
index 0000000..a501756
--- /dev/null
+++ b/src/widgets/common/header/Header.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import type { ComponentPropsWithoutRef } from 'react';
+
+import { Profile, type UserProfile } from '@/entities/user';
+import { ROUTES } from '@/shared/constants/routes';
+import { cn } from '@/shared/styles/utils/cn';
+import { LinkButton } from '@/shared/ui/button';
+import { TextLogo } from '@/shared/ui/logo';
+
+interface HeaderProps extends ComponentPropsWithoutRef<'header'> {
+ user?: UserProfile | null;
+ logoHref?: string;
+ loginHref?: string;
+ authenticatedLogoHref?: string;
+ isBackgroundBlurred?: boolean;
+ blurThreshold?: number;
+}
+
+/**
+ * ## Header
+ *
+ * @description
+ * 서비스 상단에서 사용하는 공통 Header 위젯입니다.
+ * 게스트는 로그인 CTA를, 로그인 사용자는 프로필을 표시합니다.
+ *
+ * @param user - 로그인 사용자 정보. 없으면 게스트 Header로 렌더링합니다.
+ * @param logoHref - 로고 클릭 시 이동할 경로. 기본값은 로그인 여부에 따라 결정합니다.
+ * @param authenticatedLogoHref - 로그인 상태일 때 로고 클릭 시 이동할 기본 경로입니다.
+ * @param isBackgroundBlurred - 전달하면 스크롤 감지 대신 blur 상태를 외부에서 제어합니다.
+ * @param blurThreshold - 스크롤 blur가 활성화되는 기준 scrollY 값입니다.
+ */
+export function Header({
+ authenticatedLogoHref = ROUTES.WRITING,
+ blurThreshold = 0,
+ className,
+ isBackgroundBlurred,
+ loginHref = ROUTES.LOGIN,
+ logoHref,
+ user,
+ ...props
+}: HeaderProps) {
+ const [hasScrolled, setHasScrolled] = useState(false);
+ const isLoggedIn = Boolean(user);
+ const resolvedLogoHref = logoHref ?? (isLoggedIn ? authenticatedLogoHref : ROUTES.LANDING);
+ const shouldBlur = isBackgroundBlurred ?? hasScrolled;
+
+ useEffect(() => {
+ if (isBackgroundBlurred !== undefined) {
+ return;
+ }
+
+ const handleScroll = () => {
+ setHasScrolled(window.scrollY > blurThreshold);
+ };
+
+ handleScroll();
+ window.addEventListener('scroll', handleScroll, { passive: true });
+
+ return () => {
+ window.removeEventListener('scroll', handleScroll);
+ };
+ }, [blurThreshold, isBackgroundBlurred]);
+
+ return (
+
+
+
+
+ {user ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/widgets/common/header/index.ts b/src/widgets/common/header/index.ts
new file mode 100644
index 0000000..29429dc
--- /dev/null
+++ b/src/widgets/common/header/index.ts
@@ -0,0 +1 @@
+export { Header } from './Header';