TailwindCSS에서 Breakpoint에 따라 조건부 렌더링하기
TailwindCSS에서 Breakpoint에 따라서 조건부로 렌더링하는 방법을 알아보자.
React로 웹 개발을 하다보면 보면 특정 Breakpoint에 따라서 조건부로 렌더링을 하고 싶을 때가 있다. 예를 들어, 모바일에서는 A 컴포넌트를 보여주고, 데스크탑에서는 B 컴포넌트를 보여주고 싶을 때가 있다.
TailwindCSS를 쓰기 전에는 Chakra UI와 같은 UI 라이브러리를 사용했던지라 내장된 useBreakpointValue
훅을 사용했지만, Tailwind는 css-in-js
에 비해 이러한 기능이 빈약했다.
유사한 기능을 제공하는 hook 라이브러리들이 많이 있지만, 대부분 Client-component에서만 사용하는 Hook이라 (window
객체를 참조하는) RSC 환경에서는 사용이 어려웠다.
가장 간단하게는 TailwindCSS의 hidden
과 block
클래스를 조합해서 사용할 수 있다.
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;
사실 이것은 블로그를 테스트하기 위한 똥글이다.