TailwindCSS에서 Breakpoint에 따라 조건부 렌더링하기

TailwindCSS에서 Breakpoint에 따라서 조건부로 렌더링하는 방법을 알아보자.

React로 웹 개발을 하다보면 보면 특정 Breakpoint에 따라서 조건부로 렌더링을 하고 싶을 때가 있다. 예를 들어, 모바일에서는 A 컴포넌트를 보여주고, 데스크탑에서는 B 컴포넌트를 보여주고 싶을 때가 있다.

TailwindCSS를 쓰기 전에는 Chakra UI와 같은 UI 라이브러리를 사용했던지라 내장된 useBreakpointValue 훅을 사용했지만, Tailwind는 css-in-js에 비해 이러한 기능이 빈약했다.

유사한 기능을 제공하는 hook 라이브러리들이 많이 있지만, 대부분 Client-component에서만 사용하는 Hook이라 (window 객체를 참조하는) RSC 환경에서는 사용이 어려웠다.

가장 간단하게는 TailwindCSS의 hiddenblock 클래스를 조합해서 사용할 수 있다.

return ( <div className="hidden sm:block"> <DesktopComponent /> </div> <div className="block sm:hidden"> <MobileComponent /> </div> );

이 코드를 딱 바라보면 왜인지 아래와 같이 짜고 싶다.

<Responsive component="div" base={<MobileComponent />} md={<DesktopComponent />} />

그리하여 아래와 같은 snippet을 만들었다.

Responsive.tsx
import React from "react"; import { twMerge } from "tailwind-merge"; interface ResponsiveProps<T extends React.ElementType = "div"> { component?: T; base?: React.ReactNode; sm?: React.ReactNode; md?: React.ReactNode; lg?: React.ReactNode; xl?: React.ReactNode; "2xl"?: React.ReactNode; } type ResponsivePropsWithComponent<T extends React.ElementType> = ResponsiveProps<T> & Omit<React.ComponentPropsWithoutRef<T>, keyof ResponsiveProps | "children">; type Breakpoints = "base" | "sm" | "md" | "lg" | "xl" | "2xl"; const random = Math.random().toString(36).slice(0, 4); const displayClassNames = [ "inline", // display: inline; "block", // display: block; "inline-block", // display: inline-block; "flow-root", // display: flow-root; "flex", // display: flex; "inline-flex", // display: inline-flex; "grid", // display: grid; "inline-grid", // display: inline-grid; "contents", // display: contents; "table", // display: table; "inline-table", // display: inline-table; "table-caption", // display: table-caption; "table-cell", // display: table-cell; "table-column", // display: table-column; "table-column-group", // display: table-column-group; "table-footer-group", // display: table-footer-group; "table-header-group", // display: table-header-group; "table-row-group", // display: table-row-group; "table-row", // display: table-row; "list-item", // display: list-item; ] as const; const Responsive = <T extends React.ElementType = "div">({ component, base, sm, md, lg, xl, "2xl": xl2, className, ...props }: ResponsivePropsWithComponent<T>) => { const Component = (component || "div") as React.ElementType; const breakpoints = [ { content: base, prefix: "" as const, key: "base" as const }, { content: sm, prefix: "sm:" as const, key: "sm" as const }, { content: md, prefix: "md:" as const, key: "md" as const }, { content: lg, prefix: "lg:" as const, key: "lg" as const }, { content: xl, prefix: "xl:" as const, key: "xl" as const }, { content: xl2, prefix: "2xl:" as const, key: "2xl" as const }, ].filter((bp) => bp.content !== undefined); const displayClass = displayClassNames.find((cls) => className?.includes(cls)) ?? "block"; function getResponsiveClassName(target: Breakpoints): string { const index = breakpoints.findIndex((bp) => bp.key === target); if (index === -1) return ""; const nextPrefix = breakpoints[index + 1]?.prefix || ""; if (target === "base") return `${displayClass} ${nextPrefix ? `${nextPrefix}hidden` : ""}`; return `hidden ${target}:${displayClass} ${nextPrefix ? `${nextPrefix}hidden` : ""}`; } return ( <> {breakpoints.map(({ content, key }) => ( <Component key={`breakpoint-${random}-${key}`} className={twMerge(getResponsiveClassName(key), className)} // eslint-disable-next-line @typescript-eslint/no-explicit-any {...(props as any)}> {content} </Component> ))} </> ); }; export default Responsive;

사실 이것은 블로그를 테스트하기 위한 똥글이다.