재사용의 이유
Tailwind CSS는 uility class를 html에 markup 형태로 쓰는 방식이다.
그러나 보통 좀 제대로 된 간단한 버튼 하나를 구현하려고 해도 코드과 다음과 같아 길어진다.
<button className="mx-3 mt-3 flex h-16 w-32 items-center justify-center rounded-xl border border-black bg-blue-600 text-2xl font-semibold text-blue-50 shadow-md shadow-blue-950 hover:bg-blue-200 hover:text-blue-800 active:translate-y-1 active:shadow-sm active:shadow-blue-950 sm:w-48">
Hello
</button>
위 코드를 반복해서 쓰고 싶다면 가독성이 더욱 더 떨어질 것이다.
이 코드를
<button className="btn">My Button</button>
의 형태로 재정의해서 사용하면 .jsx (.tsx) 파일의 가독성이 훨씬 올라갈 것이다.
재사용 방법
Utility class를 재사용하는 방법은 기본적으로 3가지가 있다.
1. tailwind.config.js
이미 정의되어있는 utility class를 재정의하거나, 새로운 utility class를 정의할 때 사용
예시:
theme: {
extend: {
colors: {
primary: '#F9482D',
secondary: '#DB3939',
...
2. globals.css
base는 주로 reset CSS 관련 코드
components는 재사용할 className 정의
utilities도 재사용할 className을 정의하지만 components보다 우선 순위를 가지고, 보통 작은 코드나 미디어 쿼리, pseudo-class를 정의할 때 사용
3. Typescript/Javascript로 Component화하기 (shadcn/ui library 방식)
하나의 HTML element를 Javascript component로 묶어서 재사용하기.
2. global.css
2번째 방법 globals.css에 정의하는 방법부터 보자.
globals.css에
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-main {
@apply mx-3 mt-3 flex h-16 w-32 items-center justify-center rounded-xl border border-black bg-blue-600 text-2xl font-semibold text-blue-50 shadow-md shadow-blue-950 hover:bg-blue-200 hover:text-blue-800 active:translate-y-1 active:shadow-sm active:shadow-blue-950 sm:w-48;
}
}
그리고 component에서
<button className="btn-main"> My Button </button>
으로 사용하면 된다.
그러나 globals.css에 정의하자니 다음과 같은 문제가 생긴다.
결론부터 얘기하자면 variants 때문에 생기는 문제다.
SCSS 코드
다음은 SCSS로 짠 _button.scss 파일이다.
.btn {
min-width: fit-content;
display: inline-block;
text-align: center;
// variant - text, contain, outline
&.contain {
color: $white;
border-radius: $radius-round;
border: 1px solid transparent;
&.radius {
border-radius: $radius-rg;
&.xs {
border-radius: $radius-sm;
}
}
...
그리고 component내에서 버튼 사용시
<button type="button" class="btn contain radius xs">
button
</button>
첫번째 문제점은 Tailwind CSS는 nested CSS selector를 사용하지 않는다는 점이다.
억지로 넣을 수 있는 방법은 있지만 nested selector에 대해 Tailwind CSS는
- utility-first 철학에 맞지 않고 (복잡한 디자인을 직접 markup으로 작성)
- nested selector는 실제로는 코드가 더 길어져, 운영과 확장성에서 오히려 더 유지하기 어려운 면도 있고
- separation of concerns : Tailwind는 HTML과 Javascript와 HTML내의 utilty classes를 분리해서 생각한다. (SCSS의 BEM 방식은 애초에 HTML의 nested 구조와 분리를 하지 않게 하기 위함이었는데 다시 트렌드는 역행하는 분위기다.)
요즘은 모든 것이 역행하는 분위기. (원래 3~4년전까지만해도 SPA이 빠르고 좋다하더니 이제는 왠만하면 SSR로 가자하더니, 사실 nested selector가 생겨난 이유가 HTML 구조가 nested인데 CSS class selector는 단순 나열이라 찾기 힘들어서 생겼는데 이제는 또 써보니 이게 더 복잡한 것 같다는 분위기)
아무튼 nested를 지원하지 않으니 모든 variant 조합에 대해서 다 만들어줘야하는데 다음과 같이 효율적이지 못한다.
globals.css를 계속 이용한다면
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply inline-block text-center;
}
.contain {
@apply rounded-full border border-transparent;
}
.btn-contain-rounded {
@apply btn contain rounded;
}
}
@layer utilities {
.btn-xs {
@apply h-8 px-2 text-sm font-medium;
}
}
각각의 variant에 대해 이렇게 정의하고, 실제 component에서 적용할 때
<button className="btn-contain-rounded btn-xs">My Button</button>
이렇게 짤 수도 있겠다. 그러나 굉장히 비효율적이다.
조금 더 가독성이 좋은 방법은 없을까?
3. Component로 button 코딩
만약 tailwind로 javascript(typescript) component를 만들어보자.
src/components/ui/button.tsx 생성
interface ButtonProps {
className: string;
}
const Button = ({ className }: ButtonProps) => {
return <button className="bg-blue-200">Submit</button>;
};
export { Button };
src/page.tsx
import { Button } from '@/components/ui/button';
export default function Home() {
return <Button className="bg-red-200" />;
}
만약 재사용하고 싶으면 utility function을 다음과 같이 override 시키고 싶을 것이다.
그러나 이제 npm run dev를 해보면 우리의 의도대로 button이 붉은색 색상으로 바뀌지 않는다.
globals.css에 @layer를 줄 때는 잘 되지만, 이렇게 html에 직접 넣어주면
CSS의 cascading 우선 순위에서 props로 넣어준 className이 우선 순위가 더 높으라는 보장이 없기 때문에 적용이 안 된 것이다.
이를 위한 패키지가 존재한다.
Tailwind-merge
이를 위해 우선 tailwind-merge를 설치해준다.
pnpm add tailwind-merge
이제 button.tsx에서
import { twMerge } from 'tailwind-merge';
interface ButtonProps {
className: string;
}
const Button = ({ className }: ButtonProps) => {
return <button className={twMerge('bg-blue-200', className)}>Submit</button>;
};
export { Button };
twMerge의 두번째 인자로 className prop을 넣어주면 이제 의도하던대로 버튼이 붉은색이 된다.
twMerge가 두번째 인자의 더 높은 우선순위를 보장해주기 때문이다.
Conditionals with Tailwind-merge
다음과 같이 button이 pending 상태일 때 disabled 옵션을 주고 싶을 때가 있다.
button.tsx
'use client';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
interface ButtonProps {
className: string;
}
const Button = ({ className }: ButtonProps) => {
const [pending, setPending] = useState(false);
const handleClick = () => {
setPending(true);
setTimeout(() => {
// 2초후에 pending 상태 해제
setPending(false);
}, 2000);
};
return (
<button
onClick={handleClick}
disabled={pending}
className={twMerge(
'm-5 w-32 bg-blue-200 p-4',
className,
pending ? 'bg-gray-300' : ''
)}
>
Submit
</button>
);
};
export { Button };
위의 조건문을 javascript object의 형태로 넣고 싶은 개발자를 위해 clsx라는 패키지도 있다.
clsx
install
pnpm add clsx
tailwind-merge와 javascript object 형태로 사용할 수 있는 clsx를
작업하던 button.tsx 하나의 파일에 계속 코딩할 수 있지만 자주 사용할 것이니 따로 파일로 분리한다.
src 폴더를 사용한다면 src/lib/utils.ts 경로에 파일을 생성한다.
src 폴더를 사용하지 않는다면 lib/utils.ts 경로에 파일을 생성한다.
utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
이제 cn을 import하면 조건문 className을 object의 형식으로 사용할 수 있다.
이제 위의 button.tsx를 다음과 같이 바꿀 수 있다.
'use client';
import { useState } from 'react';
import { cn } from '@/lib/utils';
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
export const Button = ({ className }: ButtonProps) => {
const [pending, setPending] = useState(false);
const handleClick = () => {
setPending(true);
setTimeout(() => {
// 2초후에 pending 상태 해제
setPending(false);
}, 2000);
};
return (
<button
onClick={handleClick}
className={cn(`m-5 w-32 bg-green-500 p-4`, className, {
'bg-gray-400': pending,
})}
>
Submit
</button>
);
};
위에 보면 template에 Submit이라는 단어가 있는데,
당연히 이것은 내가 import로 가져오는 component에서 집어넣고 싶은 문구이다.
또한 위 button을 재사용시 button 태그에 type=’submit’같은 것을 추가로 넣어줄 수 있는데 이것은 proto-type based javascript의 button의 모든 prop을 destructuring 형태로 받을 수도 있다. button.tsx는 다음 코드로 props를 추가해 줄 수 있다.
위에서 우선은
- 세번째 인자를 빼고, (단지 cn을 위한 설명을 위해 추가된 코드 전부 삭제)
- 버튼 글귀를 props로 받고
- 글귀없으니 button을 self-closing tag로 바꾸자.
button.tsx
import React from 'react';
import { cn } from '@/lib/utils';
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
const Button = ({ className, ...props }: ButtonProps) => {
return <button className={cn('bg-blue-200', className)} {...props} />;
};
export { Button };
pages.tsx
import { Button } from '@/components/ui/button';
export default function Home() {
return <Button className="">Click Me</Button>;
}
잘 적용되었지만 이제 variants를 구현해보자.
이 형태를 nested selector를 쓰지 않고 구현해보자.
class-variance-authority
variant를 설정할 수 있는 패키지다. 설치한다.
pnpm add class-variance-authority
아래와 같이 코드를 입력한다.
button.tsx
import { VariantProps, cva } from 'class-variance-authority';
import { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex h-10 items-center justify-center rounded-md border border-transparent bg-slate-900 px-4 py-2 text-sm font-medium text-slate-50 transition-colors hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-slate-100',
{
variants: {
bgcolor: {
pink: 'bg-pink-300 text-pink-900 hover:bg-pink-500',
},
outline: {
black: 'border-4 border-black',
},
size: {
sm: 'h-9 rounded-md px-2 text-xs',
lg: 'h-11 rounded-md px-8 text-base',
},
},
}
);
interface ButtonProps
extends ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {}
const Button = ({
className,
bgcolor,
outline,
size,
...props
}: ButtonProps) => {
return (
<button
className={cn(
buttonVariants({
bgcolor,
outline,
size,
className,
})
)}
{...props}
/>
);
};
export { Button };
사용예시:
모든 props를 전달 안해도 된다. 전달하지 않을 시에는 default variant로 구현된다.
import { Button } from '@/components/ui/button';
export default function Home() {
return (
<>
<Button>My Button</Button>
<Button bgcolor="pink">My Button</Button>
<Button bgcolor="pink" outline="black">
My Button
</Button>
<Button bgcolor="pink" outline="black" size="lg">
My Button
</Button>
</>
);
}
위 형태 모두 가능
React.forwardRef
import를 하는 component의 코드에서는 종종 useRef를 사용하는데
우선 어떤 상황에서 useRef hook을 사용하는지 살펴보자.
useRef 사용 예시
useRef라는 기본 리액트 hook은 여러 용도로 쓰인다.
한 가지 예로 만약 화면이 처음 로드할 때 <textarea>에 focus를 맞추고 싶다.
그러면
import { useRef } from 'react';
const Page = () => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
return (
<Textarea
ref={textareRef}
/>
...
위의 경우 화면이 로딩되자마자 cursor가 textarea에 활성화 시키게된다.
이런 식으로 특정 DOM element를 선택할 때 사용한다. 이런 useRef를 생성한 UI element에 사용하기 위해서는 ref 특성을 넣어줘야한다.
최종 코드
import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { ComponentProps } from 'react';
const buttonVariants = cva(
'inline-flex h-10 items-center justify-center rounded-md border border-transparent bg-slate-900 px-4 py-2 text-sm font-medium text-slate-50 transition-colors hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-slate-100',
{
variants: {
bgcolor: {
pink: 'bg-pink-300 text-pink-950 hover:bg-pink-500',
},
outline: {
black: 'border-4 border-black',
},
size: {
sm: 'h-9 rounded-md px-2 text-xs',
lg: 'h-11 rounded-md px-8 text-base',
},
},
}
);
interface ButtonProps
extends ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, bgcolor, outline, size, ...props }, ref) => {
return (
<button
className={cn(
buttonVariants({
bgcolor,
outline,
size,
className,
})
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };
추상화의 정의
Abstraction is used to hide background details or any unnecessary implementation about the data so that users only see the required information.
=> 코딩 세계에서 "추상화"란 표면에 필요한 데이터만 보이고 나머지 로직은 숨기는 과정
좋은 추상화란?
숨긴 로직을 우리가 이해하고 필요할 때 수정할 수 있는 추상화.
좋지 않은 추상화란?
완벽한 커스터마이징이 되지 않고, 내부 로직을 알 수 없는 library -> Ant Design, Material UI, Chakra UI 등
shadcn/ui 설치
shadcn/ui를 사용하려면 우선 설치를 해야하는데
참고: https://ui.shadcn.com/docs/installation/next
설치라 함은 내 프로젝트의 각종 환경 정보에 대해 components.json이라는 파일을 저장하는 절차다.
오래 걸리니 root 폴더에 components.json을 만들고 다음을 복사 붙이기하면 설치 안해도 된다.
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "@/app/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
npx shadcn-ui@latest add button
으로 버튼을 설치하면 components/ui 폴더에 button.tsx 파일이 생겼다.
'Frontend (Next.js Tailwind Typescript) > Next.js' 카테고리의 다른 글
Next.js 개발 요청사항 (0) | 2024.01.02 |
---|---|
Route Groups, Dynamic Routes, searchParams & File based Routing (1) | 2024.01.02 |
02-2. Schema validator: Zod (1) | 2023.12.12 |
02-2. Next.js를 위한 기본 javascript syntax (0) | 2023.12.11 |
02-4. JSON schema validator: ajv (0) | 2023.12.11 |