본문으로 건너뛰기

포트폴리오 페이지 개발 기록: HomepageHeader 구성

· 약 19분
황현규

작성자는 본래 노션을 이용해 포트폴리오 페이지를 제작하였습니다. 노션은 깔끔하고 직관적인 UI를 제공하고 여러 종류의 블록을 조립하는 형태로 페이지를 만들기 때문에 이를 통해 단기간 안에 준수한 디자인의 포트폴리오 웹페이지를 제작할 수 있었습니다.

그러나 노션에서 제공하는 블록의 디자인이 제한적이고, 특히 포트폴리오 페이지를 만들기 위한 역할과는 거리가 먼 도구라고 생각했습니다. 이에 고심 끝에 포트폴리오 페이지를 직접 제작하여 조금 더 작성자가 추구하는 디자인과 구성으로 포트폴리오 페이지를 구축하고자 하였습니다.

본 글에서는 포트폴리오 페이지 제작 중 웹페이지 방문자가 처음 접하는 HomepageHeader 컴포넌트와 docusaurus swizzle을 통한 Custom NavbarLayout을 구성하는 과정을 기록하고자 합니다.

노트

본 포트폴리오 페이지는 Docusaurus를 이용하여 제작하였습니다. Docusaurus는 React 기반의 정적 웹사이트 생성기로, React나 Markdown을 이용하여 페이지를 구성할 수 있습니다.

Docusaurus는 Infima라는 디자인 프레임워크에 기반하는데, Light/Dark 모드, Responsive Layout을 굉장히 깔끔하게 지원하고, 디자인적인 측면에서도 제공하는 컴포넌트나 색상 팔레트가 불필요하게 화려한 부분도 없고 매우 훌륭하다고 생각합니다. 아직 Infima는 개발 단계이지만, 개인적으로 지금까지 활용해본 디자인 프레임워크 중 매우 모던한 디자인을 매우 편리하게 구현할 수 있는 성장 가능성이 매우 높은 프레임워크라고 생각합니다.

Idea

작성자는 풍경 사진을 좋아합니다. 특히 도시의 야경 사진을 좋아하고, 멋진 야경을 보면 동기 부여가 되기도 하고, 단순히 바라보는 것만으로도 마음 깊숙한 곳에서 벅차오르는 느낌을 받기도 합니다. 그리고 이러한 느낌을 포트폴리오 페이지에도 담고자 했습니다.

구체적으로, 아래의 결과물처럼 포트폴리오 페이지에 처음 접속했을 때, 모니터가 일종의 창문처럼 작용하여 멋진 도시의 풍경을 보여주고, 이를 통해 방문자가 조금이나마 제가 추구하는 분위기를 느낄 수 있도록 하고자 했습니다.

Light ModeDark Mode
HomepageHeader: Light ModeHomepageHeader: Dark Mode

Implementation

이러한 HomepageHeader 섹션을 구성하는 데에 작성자는 아래와 같은 요소들을 고려하였습니다.

Seamless Light/Dark Mode Support

Infima를 사용하는 Docusaurus는 기본적으로 Light Mode와 Dark Mode를 지원합니다. 이에 자연스럽게 포트폴리오 페이지를 개발할 적에도 이 부분을 신경쓰게 되었습니다.

그 중에서 사용자가 웹페이지에 처음 방문했을 시 보게 되는 HomepageHeader 섹션에서는 위에 첨부한 사진처럼 사용자가 일종의 창문을 바라보는 느낌으로 멋진 도시의 풍경을 보여주고자 했습니다.

작성자는 HomepageHeader의 컨셉은 "창문"이기 때문에 건물에 바퀴가 달려 통째로 움직이는 것이 아닌 이상 Light Mode와 Dark Mode에서 보여지는 풍경이 같아야 하고, 이 부분이 매우 중요하다 생각했습니다.

이에 Unsplash에서 사진을 촬영한 위치가 같고 시간만 다른 사진을 찾는데 시간을 투자했습니다. 그리고 결국 2시간 정도의 시간을 들여 위에 첨부한 사진처럼 Light Mode와 Dark Mode에서 보여지는 풍경이 같은 사진을 찾을 수 있었습니다.

위와 같이 찾은 사진으로 HomepageHero 컴포넌트를 구성하고, 이를 이용해 HomepageHeader를 구성하였습니다.

const HomepageHero: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
return (
<header className={clsx("hero hero--primary", styles.hero)}>
{children}
</header>
);
};
HomepageHeader
const HomepageHeader: React.FC = () => {
const { siteConfig } = useDocusaurusContext();
const personal = siteConfig.customFields.personal as Record<string, string>;

return (
<HomepageHero>
<div className="container">
<h1 className={clsx("hero__title", styles.text, styles.title)}>
{siteConfig.tagline}
</h1>
<p className={clsx("hero__subtitle", styles.text, styles.subtitle)}>
{personal.tagline}
</p>
<div className={styles.buttonWrapper}>
<Link
className="button button--secondary button--lg"
to="/docs/category/%EC%9D%B4%EB%A0%A5%EC%84%9C"
>
{translate({
id: "pages.Home.Header.button.text",
message: "이력서 내려받기",
})}
</Link>
</div>
</div>
</HomepageHero>
);
};

이미지는 [data-theme="dark"] 특성 선택자를 이용해 Theme Mode에 따라 다르게 보여지도록 하였고, transition CSS 프로퍼티를 활용해 자연스러운 전환 효과를 구현하였습니다.

노트

--ifm-*의 형태를 가진 CSS 변수는 Infima에서 제공하는 CSS 변수입니다.

.hero {
/* ... properties */

background-position: center;
background-size: cover;
background-image: url("/img/newyork-light.jpg");
[data-theme="dark"] & {
background-image: url("/img/newyork-dark.jpg");
}
transition: background-image var(--ifm-transition-fast) var(
--ifm-transition-timing-default
);
}

@docusaurus/theme-classic에서 기본으로 제공하는 네비게이션 바는 기본적으로 아래와 같은 구성을 가지고 있습니다.

노트

Docusaurus의 Full Implementation은 여기를 참조하세요.

function Navbar(): JSX.Element {
return (
<NavbarLayout>
<NavbarContent />
</NavbarLayout>
);
}

여기서 <NavbarContent /> 컴포넌트는 바로가기 버튼, Light/Dark Mode Toggle 등 네비게이션 바에 포함되는 컴포넌트들을 구성하는 역할을 하고, <NavbarLayout />은 이러한 컴포넌트의 Container 역할을 합니다.

작성자는 HomepageHeader의 컨셉이 "창문"이기 때문에 네비게이션 바의 컨텐츠만 HomepageHeader의 사진 위에 떠있는 것처럼 구현하고 싶었습니다. 그러나 기본으로 제공하는 <NavbarLayout />은 항상 정해진 background-color를 가지고 있기 때문에 이를 구현하기 위해서는 작성자의 유스케이스에 맞게 <NavbarLayout />Swizzle 해야 했습니다.

이를 위해 작성자는 먼저 docusaurus swizzle 명령어를 이용해 NavbarLayout을 따로 구성할 수 있도록 하였습니다.

그리고 <NavbarLayout />과 이와 관련한 컴포넌트 및 훅, 유틸리티 함수를 살펴보기 시작했습니다.

Docusaurus에서 제공하는 기본적인 <NavbarLayout />의 구성은 아래와 같습니다.

function NavbarLayout({ children }: Props): JSX.Element {
const {
navbar: { hideOnScroll, style },
} = useThemeConfig();
const mobileSidebar = useNavbarMobileSidebar();
const { navbarRef, isNavbarVisible } = useHideableNavbar(hideOnScroll);
return (
<nav
ref={navbarRef}
aria-label={translate({
id: "theme.NavBar.navAriaLabel",
message: "Main",
description: "The ARIA label for the main navigation",
})}
className={clsx(
"navbar",
"navbar--fixed-top",
hideOnScroll && [
styles.navbarHideable,
!isNavbarVisible && styles.navbarHidden,
],
{
"navbar--dark": style === "dark",
"navbar--primary": style === "primary",
"navbar-sidebar--show": mobileSidebar.shown,
},
)}
>
{children}
<NavbarBackdrop onClick={mobileSidebar.toggle} />
<NavbarMobileSidebar />
</nav>
);
}

position: fixed

먼저 className을 살펴보면, navbar--fixed-top 클래스를 가지고 있고, 이는 position: sticky를 가지고 있습니다. stickyfixed와 다르게 공간을 차지하기 때문에, HomepageHeader의 사진 위에 떠있는 것처럼 구현하기 위해서 position 프로퍼티를 fixed로 바꿨습니다.

.navbarStyle {
/* ... properties */
position: fixed;
top: 0;
z-index: var(--ifm-z-index-fixed);
}

또한 이러한 behavior는 루트 페이지 / 에서만 적용되어야 했기 때문에, isRoot라는 변수를 추가하여 이를 구분하였습니다.

const location = useLocation(); // From "@docusaurus/router"
const isRoot = location.pathname === "/";

이에 최종적으로 아래와 같이 className을 구성하였습니다.

정보

navbar-home 클래스는 getNavbarHeight이라는 유틸리티 함수를 navbar에 할당된 CSS 프로퍼티의 적용 없이 작동하게끔 추가로 작성한 클래스입니다.

getNavbarHeight은 본래 Docusaurus 내부적으로 사용하는 유틸리티 함수로, document.querySelector(".navbar)?.clientHeight를 반환하는 간단한 함수입니다. 그러나 swizzling의 과정에서 루트 페이지에서는 navbar 클래스를 할당하지 않기에, 이에 대한 대용으로 navbar-home 클래스를 할당하고 document.querySelector(".navbar, .navbar-home")?.clientHeight를 반환하도록 Override 하였습니다.

clsx(
isRoot && "navbar-home",
isRoot && styles.navbarStyle,
!isRoot && "navbar",
!isRoot && "navbar--fixed-top",
// ...
);

동적인 background-color

NavBar의 컨텐츠는 초기에 스크롤이 하나도 되지 않은 상태에서는 background-colortransparent로 설정되어 있습니다. 이를 통해 HomepageHeader의 사진 위에 컨텐츠가 떠있는 것처럼 구현할 수 있습니다.

그러나 이러한 디자인을 페이지의 모든 부분에 적용하는 것은 분명히 좋지 못한 생각이었습니다. 우선 네비게이션 바 컨텐츠의 가독성이 떨어지고, 디자인적으로도 구획의 경계가 명확하지 않아 보는 데에 불편함을 느낄 수 있기 때문입니다.

이에 작성자는 초기 접속 후 스크롤을 일정 수준 내리면 background-color가 채워지도록 구현하려 하였습니다.

useScroll()

이를 구현하기 위해서는 먼저 현재 스크롤 위치를 알아야 했습니다. 이를 위해 아래와 같이 useScroll() 훅을 구현하였습니다.

subscribe()/unsubscribe()
const subscribe: typeof window.addEventListener = (
eventName,
callback,
options,
) => {
if (typeof window === "undefined") return; // If SSR, do nothing
window.addEventListener(eventName, callback, options);
};

const unsubscribe: typeof window.removeEventListener = (
eventName,
callback,
options,
) => {
if (typeof window === "undefined") return; // If SSR, do nothing
window.removeEventListener(eventName, callback, options);
};
useScroll()
const useScroll = () => {
const [scroll, setScroll] = useState({
x: 0,
y: 0,
});

const onScroll = () => {
setScroll({
x: window.scrollX,
y: window.scrollY,
});
};

useEffectOnce(() => {
// From `usehooks-ts`
subscribe("scroll", onScroll);
return () => {
unsubscribe("scroll", onScroll);
};
});

return scroll;
};
useIsMobile()

또한 현재 환경이 모바일인지 데스크탑인지 알아야 했습니다. 이는 데스크탑과 모바일에서 표시되는 HomepageHeader의 높이가 다르기 때문입니다. 데스크탑 환경에서는 그 높이가 더 높기 때문에 스크롤이 충분히 내려갔을때 background-color가 채워지도록 하고, 모바일 환경에서는 그 높이가 더 낮기 때문에 스크롤이 조금만 내려가도 background-color가 채워지도록 하는 것이 UX 측면에서 훨씬 이점이 있겠다고 생각했습니다.

이에 처음에는 CSS media query를 이용하는 방안을 생각했으나, 혹시나 이보다 더 편리하고 Typescript 단에서 더 편리하게 활용할 수 있는 방안이 있을까에 관해 고민하며 구글링을 하다, Docusaurus에서 useWindowSize()라는 훅을 제공하는 것을 알게 되었습니다. (See: https://github.com/facebook/docusaurus/discussions/5858)

이에 작성자는 위의 훅을 기반으로 아래와 같이 간단한 useIsMobile() 훅을 구현하였습니다.

const useIsMobile = () => {
const windowSize = useWindowSize();
const isMobile = windowSize === "mobile";

return isMobile;
};
backgroundColor

위의 두 훅을 이용해 아래와 같이 backgroundColor 변수를 정의하였습니다.

/**
* @description
* Upon this threshold, the navbar will be filled with a background color.
* This value only applies when display size is larger than @const mobileWidth.
*/
const navBarFillThreshold = 150;

const isMobile = useIsMobile();
const { y } = useScroll();
const backgroundColor = isRoot
? y <= (isMobile ? 0 : navBarFillThreshold)
? "transparent"
: "var(--ifm-navbar-background-color)"
: undefined;

Responsive Layout

Infima는 기본적으로 너비 998px를 기준으로 모바일 환경과 데스크탑 환경에 대한 Responsive Layout을 지원합니다.

이에 모바일과 데스크탑 환경에서는 화면의 비율 차이에 따라 HomepageHeader 섹션의 높이를 다르게 설정하였습니다.

또한 작성자는 13인치 맥북 에어로 본 포트폴리오 페이지를 개발했는데, 이 때문에 더 큰 화면에서 어떤 식으로 페이지가 보일지에 관한 의문을 가지게 되었습니다. 이에 자택에 있는 27인치 모니터를 이용해 테스트를 해보았는데, 아래와 같이 작게 보이는 것을 확인할 수 있었습니다.

Too Large
화면의 크기 큰 경우의 HomepageHeader 섹션

이에 Infima의 .container 에서 큰 화면을 구분하는 데에 사용하는 기준점인 1440px를 참고하여 1442px를 기준으로 데스크탑의 너비가 이를 넘는 경우에는 HomepageHeader 섹션의 크기를 더 크게 설정하였습니다.

최종적으로 아래와 같은 SCSS 코드를 통해 각 환경에 맞는 섹션의 높이를 설정하였습니다.

.hero {
// Mobile Layout
padding: 5rem 0;

// Laptop Layout (with small ~ medium screen size)
@media screen and (min-width: 998px) {
padding: 10rem 0;
}

// Desktop Layout (with large screen size)
@media screen and (min-width: 1442px) {
padding: 20rem 0;
}
}

Result & Conclusion

위와 같은 과정을 거쳐 최종적으로 아래와 같은 NavBar swizzling을 통해 위에서 언급한 것과 같이 HomepageHeader 섹션이 일종의 "창문" 처럼 느껴지도록 하는 구현을 완성할 수 있었습니다.

import styles from "./styles.module.css";
// ...

function NavbarLayout({ children }) {
const {
navbar: { hideOnScroll, style },
} = useThemeConfig();
const mobileSidebar = useNavbarMobileSidebar();
const { navbarRef, isNavbarVisible } = useHideableNavbar(hideOnScroll);

// Check if the current location is root
const location = useLocation(); // From "@docusaurus/router"
const isRoot = location.pathname === "/";

// Decide whether to fill the navbar with a background color
const { y } = useScroll();
const isMobile = useIsMobile();
const backgroundColor = isRoot
? y <= (isMobile ? 0 : navBarFillThreshold)
? "transparent"
: "var(--ifm-navbar-background-color)"
: undefined;
return (
<nav
ref={navbarRef}
aria-label={translate({
id: "theme.NavBar.navAriaLabel",
message: "Main",
description: "The ARIA label for the main navigation",
})}
className={clsx(
isRoot && "navbar-home",
isRoot && styles.navbarStyle,
!isRoot && "navbar",
!isRoot && "navbar--fixed-top",
hideOnScroll && [
styles.navbarHideable,
!isNavbarVisible && styles.navbarHidden,
],
{
"navbar--dark": style === "dark",
"navbar--primary": style === "primary",
"navbar-sidebar--show": mobileSidebar.shown,
},
)}
style={{
backgroundColor,
}}
>
{children}
<NavbarBackdrop onClick={mobileSidebar.toggle} />
<NavbarMobileSidebar />
</nav>
);
}
Light Mode
Landing Page: Light Mode
Dark Mode
Landing Page: Dark Mode

작성자는 개인적으로 본 포트폴리오 작업이 꽤나 재밌는 경험이었습니다. 그 중에서도 특히 HomepageHeader의 구성은 작성자의 지향이 담겨있기도 하고, 방문자의 유저 경험을 높이기 위해 고민하고 시간을 투자한 부분이 많았기 때문에 더욱 뜻 깊은 경험이었습니다.

또한 Docusaurus의 Swizzling 기능을 이용해 기존의 컴포넌트를 재사용하면서도 작성자의 유스케이스에 맞게 커스터마이징할 수 있었던 것도 매우 좋은 경험이었으며, Docusaurus와 CSS, React에 대한 이해도를 높일 수 있었던 좋은 경험이었습니다.

여기에 더해 Docusaurus의 기존 소스코드를 분석하면서 Facebook에서 제공하는 Docusaurus 오픈소스의 코드 구성 방식을 보면서 네이밍 컨벤션, 폴더 구조, 코드 스타일 등에 관한 깊은 인사이트도 얻을 수 있었습니다.