Chakra UI 실전: 스타일 props, 테마 토큰, 다크 모드와 Emotion 스택의 비용
이 글의 핵심
Chakra UI는 props만으로 빠르게 화면을 만들 수 있지만, 스타일이 런타임에 Emotion으로 해석되기 때문에 SSR 설정이나 번들 크기에서 비용이 드러납니다. styled-system과 테마 토큰이 어떻게 연결되는지, 키보드 포커스와 접근성을 유지하는 방법, 자주 막히는 문제의 해결 순서, MUI와 비교해 선택하는 기준을 함께 설명합니다.
이 글의 핵심
Chakra UI의 스타일 props로 레이아웃과 폼을 만들고, 테마 토큰과 다크 모드, 반응형 문법을 적용하는 방법을 다룹니다. 예제는 널리 쓰이는 v2 API 기준이며, 2024년 말 나온 v3에서 달라진 점과 런타임 CSS-in-JS가 가진 비용, 실제로 자주 막히는 지점을 함께 설명합니다.
Chakra UI를 고르게 되는 이유
버튼·모달·폼 컴포넌트를 매번 새로 만들고 있어요
모달 하나만 해도 포커스 가두기, Esc로 닫기, 배경 스크롤 막기, 닫은 뒤 원래 버튼으로 포커스 돌려주기 같은 동작을 직접 구현해야 합니다. Chakra UI는 이런 동작이 들어간 컴포넌트를 수십 개 제공해 화면 구성에 집중할 수 있게 합니다.
접근성을 챙길 여유가 없어요
Chakra의 모달, 메뉴, 탭 등은 WAI-ARIA 패턴에 맞는 역할 속성과 키보드 동작을 기본으로 갖추고 있습니다. 다만 “자동으로 보장”되는 것은 아닙니다. 이미지 대체 텍스트, 입력 필드와 라벨 연결, 색 대비처럼 개발자가 채워야 하는 부분은 여전히 남아 있습니다.
다크 모드를 적은 코드로 넣고 싶어요
v2는 색상 모드 상태 관리와 저장(localStorage), 시스템 설정 연동을 내장하고 있어, 컴포넌트 안에서 모드별 값을 고르는 것만으로 다크 모드를 지원할 수 있습니다.
Chakra UI란?
핵심 특징
Chakra UI는 스타일 props 중심의 React 컴포넌트 라이브러리입니다. <Box p={4} bg="gray.100">처럼 CSS 속성을 컴포넌트 props로 바로 쓰고, 그 값이 테마의 디자인 토큰(간격 스케일, 색상 팔레트)으로 해석된다는 것이 가장 큰 특징입니다.
주요 장점:
- 수십 개의 컴포넌트: 레이아웃, 폼, 오버레이, 피드백 컴포넌트를 즉시 사용
- 접근성: WAI-ARIA 패턴을 따르는 키보드·포커스 동작
- 테마: 토큰과 컴포넌트 스타일을 한곳에서 확장
- 다크 모드: 색상 모드 전환 내장
- TypeScript: 스타일 props와 토큰 이름까지 타입 지원
스타일 props 방식의 장점은 속도입니다. CSS 파일과 컴포넌트 파일을 오가지 않고, 클래스 이름을 짓지 않아도 됩니다. 단점은 JSX가 스타일 속성으로 길어지고, 같은 스타일 조합이 여러 곳에 흩어지기 쉽다는 것입니다. 세 번 이상 반복되는 조합은 테마의 컴포넌트 variant나 래퍼 컴포넌트로 묶는 습관을 들여야 규모가 커져도 일관성이 유지됩니다.
설치 및 설정
설치
npm install @chakra-ui/react@2 @emotion/react @emotion/styled framer-motion
버전을 명시한 이유가 있습니다. 지금 npm install @chakra-ui/react를 실행하면 v3가 설치되는데, v3는 이 글의 예제와 API가 크게 다릅니다. extendTheme는 createSystem으로, colorScheme prop은 colorPalette로, FormControl은 Field로, Stack의 spacing은 gap으로 바뀌었고, useColorMode는 제거되어 다크 모드를 next-themes 같은 별도 라이브러리로 처리합니다. 애니메이션도 CSS 기반으로 바뀌어 framer-motion 의존성이 없어졌습니다. v2 예제를 v3 프로젝트에 붙이면 export 'extendTheme' was not found 같은 에러가 나거나 props가 조용히 무시됩니다. 기존 v2 프로젝트라면 버전을 고정하고, 새 프로젝트라면 v3 문서를 기준으로 시작하는 것이 맞습니다.
Provider 설정
import { ChakraProvider } from '@chakra-ui/react';
export default function App({ Component, pageProps }) {
return (
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
);
}
ChakraProvider는 테마 객체를 React 컨텍스트로 내려보내고, 전역 CSS 리셋과 색상 모드 관리를 설정합니다. 위 코드는 Next.js Pages Router의 _app.tsx 형태입니다. App Router에서는 ChakraProvider가 컨텍스트와 훅을 쓰기 때문에 서버 컴포넌트인 layout.tsx에 바로 둘 수 없고, 'use client'를 붙인 providers.tsx를 만들어 그 안에서 감싼 뒤 레이아웃에서 불러와야 합니다. v2에서는 @chakra-ui/next-js의 CacheProvider로 Emotion 스타일을 서버 HTML에 함께 넣어야 첫 화면이 스타일 없이 깜빡이지 않습니다.
기본 컴포넌트
import { Button, Box, Text, Heading, Stack } from '@chakra-ui/react';
export default function Home() {
return (
<Box p={8}>
<Heading mb={4}>Welcome to Chakra UI</Heading>
<Text mb={4}>Build accessible React apps with speed</Text>
<Stack direction="row" spacing={4}>
<Button colorScheme="blue">Primary</Button>
<Button colorScheme="green">Secondary</Button>
<Button variant="outline">Outline</Button>
</Stack>
</Box>
);
}
p={8}의 8은 픽셀이 아니라 테마의 간격 스케일 인덱스입니다. v2 기본 테마에서 space.8은 2rem(32px)이므로, 디자이너가 준 값을 그대로 숫자로 넣으면 네 배 크기가 됩니다. 스케일에 없는 값이 필요하면 p="10px"처럼 단위를 붙인 문자열을 넘깁니다. colorScheme="blue"는 버튼 색 하나가 아니라 blue.500(기본), blue.600(hover), blue.700(active) 같은 팔레트 전체를 버튼에 연결하는 prop이라, 한 prop으로 상태별 색이 함께 바뀝니다.
Layout
import { Box, Flex, Grid, GridItem, Container, Stack } from '@chakra-ui/react';
export default function Layout() {
return (
<Container maxW="container.xl">
<Flex justify="space-between" align="center" mb={8}>
<Box>Logo</Box>
<Stack direction="row" spacing={4}>
<Box>Home</Box>
<Box>About</Box>
</Stack>
</Flex>
<Grid templateColumns="repeat(3, 1fr)" gap={6}>
<GridItem>Card 1</GridItem>
<GridItem>Card 2</GridItem>
<GridItem>Card 3</GridItem>
</Grid>
</Container>
);
}
Flex와 Grid는 display: flex/grid를 설정한 Box이고, justify·align·templateColumns 같은 prop은 해당 CSS 속성의 축약입니다. Stack은 자식 사이에 간격을 주는 컴포넌트인데, v2에서 spacing은 자식에 margin을 넣는 방식으로 구현되어 있어 조건부 렌더링으로 null이 끼거나 자식이 display: none이면 간격이 어긋나는 경우가 있습니다. 이럴 때는 CSS gap을 쓰는 Flex gap={4}가 더 예측 가능합니다. 메뉴처럼 의미가 있는 영역은 Box 대신 as="nav", as="ul"로 적절한 HTML 요소를 지정해야 스크린 리더가 구조를 이해할 수 있습니다.
Form
import {
FormControl,
FormLabel,
FormErrorMessage,
Input,
Button,
VStack,
} from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={4}>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input {...register('email', { required: 'Email is required' })} />
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>Password</FormLabel>
<Input
type="password"
{...register('password', { required: 'Password is required' })}
/>
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
</FormControl>
<Button type="submit" colorScheme="blue" width="full">
Submit
</Button>
</VStack>
</form>
);
}
FormControl이 하는 일은 눈에 보이는 것보다 많습니다. 내부 컨텍스트로 FormLabel의 for와 Input의 id를 자동으로 연결하고, isInvalid가 참이면 입력에 aria-invalid를 붙이고 FormErrorMessage를 보여 주며, 그 메시지를 aria-describedby로 입력과 연결합니다. 그래서 스크린 리더 사용자는 입력칸에 포커스했을 때 에러 내용을 함께 듣게 됩니다. FormErrorMessage는 isInvalid가 거짓이면 렌더링되지 않으므로 조건문을 따로 둘 필요가 없습니다.
react-hook-form의 register는 ref, onChange, onBlur, name을 돌려주고 Chakra의 Input은 ref를 실제 <input>으로 전달하므로 두 라이브러리는 그대로 맞물립니다. 반면 Chakra의 Select가 아닌 커스텀 컴포넌트나 NumberInput처럼 값을 직접 다루는 컴포넌트는 ref 연결이 맞지 않아 값이 비어 제출되는 일이 있는데, 이때는 react-hook-form의 Controller로 감싸야 합니다. 이메일 필드에는 type="email"과 autoComplete="email"을 주면 모바일 키보드와 브라우저 자동 완성이 제대로 동작합니다.
Theming
import { extendTheme, ChakraProvider } from '@chakra-ui/react';
const theme = extendTheme({
colors: {
brand: {
50: '#e3f2fd',
100: '#bbdefb',
500: '#2196f3',
900: '#0d47a1',
},
},
fonts: {
heading: 'Georgia, serif',
body: 'Arial, sans-serif',
},
components: {
Button: {
baseStyle: {
fontWeight: 'bold',
},
variants: {
solid: {
bg: 'brand.500',
color: 'white',
},
},
},
},
});
export default function App() {
return (
<ChakraProvider theme={theme}>
{/* 컴포넌트 */}
</ChakraProvider>
);
}
extendTheme는 기본 테마 위에 넘긴 객체를 깊게 병합합니다. 그래서 colors.brand만 추가해도 기존 gray, blue 팔레트는 그대로 남습니다. 이 예제에는 실제로 자주 걸리는 함정이 하나 있습니다. brand 팔레트에 50, 100, 500, 900 네 단계만 정의했는데, <Button colorScheme="brand">를 쓰면 Chakra의 버튼 스타일은 hover에 brand.600, active에 brand.700을 찾습니다. 없는 토큰은 에러 없이 무시되므로 마우스를 올리면 배경이 사라지는 버튼이 됩니다. colorScheme로 쓸 팔레트는 50부터 900까지 열 단계를 모두 채워야 합니다.
두 번째는 variants.solid를 객체로 덮어쓴 부분입니다. v2의 기본 solid variant는 colorScheme에 따라 색을 계산하는 함수인데, 이를 고정 객체로 바꾸면 모든 solid 버튼이 colorScheme과 상관없이 brand.500이 됩니다. 의도가 “기본 버튼 색을 브랜드 색으로”라면 variant를 덮어쓰는 대신 defaultProps: { colorScheme: 'brand' }를 주는 편이 hover·active·다크 모드 색까지 함께 유지됩니다. 테마를 크게 바꿀 때는 npx @chakra-ui/cli tokens ./theme.ts로 타입을 생성하면 colorScheme="brnad" 같은 오타를 컴파일 단계에서 잡을 수 있습니다.
Dark Mode
import { Box, Button, useColorMode, useColorModeValue } from '@chakra-ui/react';
export default function DarkModeToggle() {
const { colorMode, toggleColorMode } = useColorMode();
const bg = useColorModeValue('white', 'gray.800');
const color = useColorModeValue('black', 'white');
return (
<Box bg={bg} color={color} p={8}>
<Button onClick={toggleColorMode}>
Toggle {colorMode === 'light' ? 'Dark' : 'Light'}
</Button>
</Box>
);
}
useColorModeValue(light, dark)는 현재 모드에 맞는 값을 돌려주는 훅입니다. 간단하지만 색이 필요한 곳마다 훅을 호출해야 하고, 모드별 색 조합이 컴포넌트 곳곳에 흩어진다는 단점이 있습니다. 규모가 커지면 테마의 시맨틱 토큰(semanticTokens: { colors: { 'bg.surface': { default: 'white', _dark: 'gray.800' } } })으로 의미 단위 색을 정의하고 컴포넌트에서는 bg="bg.surface"만 쓰는 방식이 관리하기 쉽습니다. 일회성으로는 _dark={{ bg: 'gray.800' }} 같은 의사 prop도 쓸 수 있습니다.
다크 모드에서 가장 흔한 불만은 새로고침할 때 밝은 화면이 잠깐 번쩍이는 현상입니다. 저장된 모드는 localStorage에 있어서 JavaScript가 실행되기 전까지 서버 HTML은 기본(밝은) 모드로 그려지기 때문입니다. v2에서는 <ColorModeScript initialColorMode={theme.config.initialColorMode} />를 <body> 맨 앞에 넣으면 React보다 먼저 실행되는 작은 스크립트가 <html>에 모드 클래스를 붙여 깜빡임을 막아 줍니다. SSR 환경에서 모드를 서버가 알아야 한다면 cookieStorageManager로 모드를 쿠키에 저장하는 방법도 있습니다.
Responsive
import { Box, Text } from '@chakra-ui/react';
export default function Responsive() {
return (
<Box
width={{ base: '100%', md: '50%', lg: '25%' }}
p={{ base: 4, md: 6, lg: 8 }}
>
<Text fontSize={{ base: 'md', md: 'lg', lg: 'xl' }}>
Responsive Text
</Text>
</Box>
);
}
반응형 값은 모바일 우선(min-width 미디어 쿼리)으로 해석됩니다. base는 모든 크기에 적용되는 기본값이고, md는 기본 테마에서 48em(768px) 이상일 때 덮어씁니다. 그래서 “태블릿 이하에서만” 같은 조건은 base에 모바일 값을, md에 데스크톱 값을 주는 식으로 뒤집어 생각해야 합니다. 객체 문법 대신 p={[4, 6, 8]}처럼 배열 문법도 쓸 수 있는데, 배열은 base, sm, md, lg... 순서로 대응되어 sm을 건너뛰려면 [4, null, 6]처럼 null을 넣어야 합니다. 이 순서를 헷갈리기 쉬워서 팀 코드에서는 객체 문법이 더 읽기 쉽습니다. 컴포넌트 자체를 크기별로 바꿔야 한다면 useBreakpointValue 훅이 있지만, SSR 첫 렌더에서는 화면 크기를 몰라 기본값으로 그렸다가 바뀌므로 레이아웃이 튈 수 있습니다. 가능하면 CSS로 해결되는 스타일 props를 우선 쓰는 것이 좋습니다.
내부 스택: Emotion·styled-system·테마 토큰
Chakra UI v2 계열은 스타일을 CSS-in-JS(Emotion) 로 생성합니다. Box에 넘기는 p, mt, colorScheme 같은 속성은 styled-system 규칙에 따라 테마 토큰(스페이스 스케일, 컬러 팔레트)으로 해석되고, 최종적으로 고유 클래스명이 붙은 스타일이 주입됩니다. 따라서 “인라인 스타일 객체를 매번 새로 만든다”기보다 테마 기반의 일관된 디자인 토큰을 쓰는 쪽에 가깝습니다.
프로덕션 함의: 런타임 CSS-in-JS는 첫 페인트 이후 스타일 삽입·SSR 시 스타일 순서 이슈를 팀 설정과 맞춰야 합니다. Chakra v3는 아키텍처가 크게 바뀌었으므로, 신규 프로젝트에서는 공식 마이그레이션 가이드와 디자인 시스템 버전을 먼저 고정하는 것이 안전합니다.
런타임 CSS-in-JS의 비용을 조금 더 구체적으로 보면 이렇습니다. 컴포넌트가 렌더링될 때마다 props를 스타일 객체로 바꾸고, 그 객체를 직렬화해 해시를 계산하고, 처음 보는 조합이면 <style> 태그에 규칙을 삽입합니다. 대부분은 캐시되어 체감하기 어렵지만, 수백 개의 행을 가진 표나 스크롤마다 다시 그려지는 목록처럼 렌더링이 잦은 곳에서는 프로파일러에 스타일 계산 시간이 드러납니다. 또 React 서버 컴포넌트는 컨텍스트와 런타임 스타일 주입을 쓸 수 없어서 Chakra 컴포넌트는 항상 클라이언트 컴포넌트 안에서 써야 합니다. 이 때문에 App Router 중심의 새 프로젝트에서는 빌드 타임에 CSS를 뽑는 방식(Tailwind CSS, Panda CSS, CSS Modules)을 검토하는 팀도 많습니다. v3는 Emotion을 유지하면서 내부 구조를 Panda CSS와 비슷한 레시피 방식으로 바꿔 성능을 개선했지만, 런타임 스타일링이라는 성격 자체는 그대로입니다.
Next.js·SSR·하이드레이션
App Router·Pages Router 모두 서버에서 HTML을 먼저 보내는 경우, 테마·Color Mode는 초기 색상 플래시(FOUC) 와 연결됩니다. 일반적인 대응은 초기 테마 힌트를 쿠키/헤더에 싣기, ColorModeScript 를 _document에 배치(버전별 문서 확인)하는 식입니다. Emotion 캐시를 서버/클라이언트에 공유하는 설정이 누락되면 클래스가 어긋날 수 있으므로, 공식 Next.js 예제와 패키지 메이저 버전을 함께 맞춥니다.
번들 크기·트리 쉐이킹
@chakra-ui/react에서 필요한 컴포넌트만 import하는 것이 기본입니다. 전역으로 모든 컴포넌트를 등록하는 패턴은 번들 팽창으로 이어집니다. 아이콘 패키지(@chakra-ui/icons)는 사용 아이콘만 가져오고, 차트·에디터처럼 무거운 확장은 지연 로딩을 검토합니다.
접근성·키보드·포커스
Chakra는 많은 컴포넌트에 ARIA 역할·키보드 동작을 기본 탑재합니다. 다만 커스텀 as 폴리모피즘으로 시맨틱이 바뀌면(예: Button as="div") 키보드 포커스가 깨질 수 있습니다. 포커스 링을 끄는 스타일은 WCAG 대비와 함께 검토해야 합니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
| 다크 모드가 한 박자 늦게 적용 | SSR·쿠키·ColorModeScript·시스템 테마 동기화 |
| 스타일이 전혀 안 먹음 | ChakraProvider 누락·상위에서 emotion 캐시 중복 |
| 리스트/모달에서 스크롤 잠김 | Portal 컨테이너·blockScrollOnMount·중첩 모달 |
| 테스트에서 테마 토큰 오류 | Jest·Vitest에 별도 Chakra wrapper 또는 최소 테마 주입 |
| v2 코드를 v3로 옮겼는데 API가 다름 | 마이그레이션 가이드·컴포넌트 이름 변경표 |
| hover 시 버튼 배경이 사라짐 | 커스텀 팔레트에 600·700 단계 누락 |
제가 Chakra 프로젝트에서 가장 오래 헤맨 문제는 표의 두 번째 줄, “스타일이 일부만 먹는” 경우였습니다. 원인은 모노레포에서 패키지마다 @emotion/react가 다른 버전으로 설치되어 Emotion 인스턴스가 두 개 생긴 것이었고, 테마 컨텍스트가 한쪽에만 전달되어 일부 컴포넌트가 기본 테마로 그려졌습니다. 콘솔에 You are loading @emotion/react when it is already loaded 경고가 뜬다면 이 상황이므로, npm ls @emotion/react로 중복 설치를 확인하고 한 버전으로 맞추면 해결됩니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. MUI와 비교하면 어떤가요?
A. Chakra UI는 스타일 props 중심이라 API가 간결하고 브랜드에 맞게 바꾸기 쉽습니다. MUI는 Material Design 기반으로 Data Grid·Date Picker 같은 복잡한 컴포넌트와 생태계가 더 넓어 대규모 관리 화면에 유리합니다.
Q. 번들 크기는 어떤가요?
A. 사용한 컴포넌트 위주로 트리 셰이킹되지만, Emotion 런타임과 공통 스타일 시스템, v2의 경우 framer-motion이 기본으로 포함되어 가벼운 편은 아닙니다. 번들 분석 도구로 실제 크기를 확인하는 것이 좋습니다.
Q. Next.js에서 사용할 수 있나요?
A. 네. Pages Router에서는 _app에 Provider를 두면 되고, App Router에서는 'use client' Provider 컴포넌트와 Emotion 캐시 설정이 필요합니다. Chakra 컴포넌트는 서버 컴포넌트에서 직접 쓸 수 없습니다.