테마가 적용되는 컴포넌트 만들기
테마가 적용되는 컴포넌트 만들기 (Creating themed components)
앱의 테마를 그대로 받아 쓸 수 있는 완전히 커스텀한 컴포넌트를 만드는 방법을 배워볼게요. 내장 컴포넌트처럼 동작하면서도 테마로 스타일을 통제할 수 있는 컴포넌트를 직접 만들어볼 거예요.
출처: 문서
본문
소개 (Introduction)
Material UI는 아주 강력한 테마 기능을 제공해요. 테마에 여러분만의 컴포넌트를 추가해서, 마치 내장 컴포넌트인 것처럼 취급할 수 있게 해주죠.
Material UI 위에 컴포넌트 라이브러리를 만들고 있다면, 아래 단계별 가이드를 따라 하면 여러 프로젝트에서 테마를 적용할 수 있는 커스텀 컴포넌트를 만들 수 있어요.
아니면 제공되는 템플릿을 컴포넌트의 출발점으로 사용해도 좋아요.
:::info 컴포넌트를 단일 프로젝트에서만 사용한다면 테마에 연결할 필요는 없어요. :::
단계별 가이드 (Step-by-step guide)
이 가이드는 아래의 통계(statistics) 컴포넌트를 만드는 과정을 안내해요. 내장 Material UI 컴포넌트처럼 앱의 테마를 그대로 받아들이는 컴포넌트예요:
import { styled } from '@mui/material/styles';
const StatRoot = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
}));
const StatValue = styled('div')(({ theme }) => ({
...theme.typography.h3,
}));
const StatUnit = styled('div')(({ theme }) => ({
...theme.typography.body2,
color: theme.palette.text.secondary,
...theme.applyStyles('dark', {
color: 'inherit',
}),
}));
export default function StatComponent() {
return (
<StatRoot>
<StatValue>19,267</StatValue>
<StatUnit>Active users / month</StatUnit>
</StatRoot>
);
}
1. 컴포넌트 슬롯(Slots) 만들기
슬롯(slot)을 사용하면 컴포넌트의 각 요소를 따로 커스터마이즈할 수 있어요. 테마의 styleOverrides와 테마의 variants에서 해당 이름을 지정해서 말이죠.
이 통계 컴포넌트는 세 개의 슬롯으로 이뤄져 있어요:
root: 컴포넌트의 컨테이너value: 통계의 숫자unit: 통계의 단위 또는 설명
:::success
원하는 이름을 붙여도 되지만, 라이브러리의 나머지 부분과 일관성을 위해 가장 바깥쪽 컨테이너 요소에는 root를 쓰길 권장해요.
:::
import { styled } from '@mui/material/styles';
const StatRoot = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
}));
const StatValue = styled('div')(({ theme }) => ({
...theme.typography.h3,
}));
const StatUnit = styled('div')(({ theme }) => ({
...theme.typography.body2,
color: theme.palette.text.secondary,
...theme.applyStyles('dark', {
color: 'inherit',
}),
}));
const Label = styled('div')(({ theme }) => ({
borderRadius: '2px',
padding: theme.spacing(0, 1),
color: 'white',
position: 'absolute',
...theme.typography.body2,
fontSize: '0.75rem',
fontWeight: 500,
backgroundColor: '#ff5252',
}));
export default function StatSlots() {
return (
<StatRoot
sx={{ outline: '1px solid #ff5252', outlineOffset: 4, position: 'relative' }}
>
<StatValue sx={{ outline: '1px solid #ff5252', position: 'relative' }}>
19,267
<Label sx={{ right: 0, top: 4, transform: 'translateX(100%)' }}>value</Label>
</StatValue>
<StatUnit sx={{ outline: '1px solid #ff5252', position: 'relative' }}>
Active users / month
<Label sx={{ right: 0, top: 2, transform: 'translateX(100%)' }}>unit</Label>
</StatUnit>
<Label sx={{ left: -4, top: 4, transform: 'translateX(-100%)' }}>root</Label>
</StatRoot>
);
}
아래처럼 styled API에 name과 slot 파라미터를 함께 사용해서 슬롯을 만들면 돼요:
import * as React from 'react';
import { styled } from '@mui/material/styles';
const StatRoot = styled('div', {
name: 'MuiStat', // The component name
slot: 'root', // The slot name
})(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
}));
const StatValue = styled('div', {
name: 'MuiStat',
slot: 'value',
})(({ theme }) => ({
...theme.typography.h3,
}));
const StatUnit = styled('div', {
name: 'MuiStat',
slot: 'unit',
})(({ theme }) => ({
...theme.typography.body2,
color: theme.palette.text.secondary,
}));
2. 컴포넌트 만들기
앞 단계에서 만든 슬롯들을 조합해서 컴포넌트를 만들어봐요:
// /path/to/Stat.js
import * as React from 'react';
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
})(…);
const StatValue = styled('div', {
name: 'MuiStat',
slot: 'value',
})(…);
const StatUnit = styled('div', {
name: 'MuiStat',
slot: 'unit',
})(…);
const Stat = React.forwardRef(function Stat(props, ref) {
const { value, unit, ...other } = props;
return (
<StatRoot ref={ref} {...other}>
<StatValue>{value}</StatValue>
<StatUnit>{unit}</StatUnit>
</StatRoot>
);
});
export default Stat;
이 시점에서 Stat 컴포넌트에 아래처럼 테마를 적용할 수 있게 돼요:
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
components: {
// the component name defined in the `name` parameter
// of the `styled` API
MuiStat: {
styleOverrides: {
// the slot name defined in the `slot` and `overridesResolver` parameters
// of the `styled` API
root: {
backgroundColor: '#121212',
},
value: {
color: '#fff',
},
unit: {
color: '#888',
},
},
},
},
});
3. ownerState로 슬롯 스타일링하기
슬롯 기반 props나 내부 상태를 기준으로 스타일을 지정해야 할 때는, 그 값들을 ownerState 객체에 담아 각 슬롯에 prop으로 전달해요.
ownerState는 특별한 이름이라서 styled API를 통해 DOM으로 전파(spread)되지 않아요.
Stat 컴포넌트에 variant prop을 추가하고, 그 값을 이용해 root 슬롯을 스타일링해볼게요:
const Stat = React.forwardRef(function Stat(props, ref) {
+ const { value, unit, variant, ...other } = props;
+
+ const ownerState = { ...props, variant };
return (
- <StatRoot ref={ref} {...other}>
- <StatValue>{value}</StatValue>
- <StatUnit>{unit}</StatUnit>
- </StatRoot>
+ <StatRoot ref={ref} ownerState={ownerState} {...other}>
+ <StatValue ownerState={ownerState}>{value}</StatValue>
+ <StatUnit ownerState={ownerState}>{unit}</StatUnit>
+ </StatRoot>
);
});
그다음 슬롯에서 ownerState를 읽어서 variant prop에 따라 스타일을 적용하면 돼요.
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
- })(({ theme }) => ({
+ })(({ theme, ownerState }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
+ ...ownerState.variant === 'outlined' && {
+ border: `2px solid ${theme.palette.divider}`,
+ },
}));
4. 테마 기본 props 지원하기
프로젝트마다 컴포넌트의 기본 props를 다르게 커스터마이즈하려면 useThemeProps API를 사용해야 해요.
+ import { useThemeProps } from '@mui/material/styles';
- const Stat = React.forwardRef(function Stat(props, ref) {
+ const Stat = React.forwardRef(function Stat(inProps, ref) {
+ const props = useThemeProps({ props: inProps, name: 'MuiStat' });
const { value, unit, ...other } = props;
return (
<StatRoot ref={ref} {...other}>
<StatValue>{value}</StatValue>
<StatUnit>{unit}</StatUnit>
</StatRoot>
);
});
그러면 아래처럼 컴포넌트의 기본 props를 커스터마이즈할 수 있어요:
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiStat: {
defaultProps: {
variant: 'outlined',
},
},
},
});
TypeScript
TypeScript를 쓴다면 컴포넌트의 props와 ownerState에 대한 인터페이스를 만들어야 해요:
interface StatProps {
value: number | string;
unit: string;
variant?: 'outlined';
}
interface StatOwnerState extends StatProps {
// …key value pairs for the internal state that you want to style the slot
// but don't want to expose to the users
}
그런 다음 컴포넌트와 슬롯에서 이 인터페이스들을 사용하면 돼요.
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
})<{ ownerState: StatOwnerState }>(({ theme, ownerState }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
// typed-safe access to the `variant` prop
...(ownerState.variant === 'outlined' && {
border: `2px solid ${theme.palette.divider}`,
boxShadow: 'none',
}),
}));
// …do the same for other slots
const Stat = React.forwardRef<HTMLDivElement, StatProps>(function Stat(inProps, ref) {
const props = useThemeProps({ props: inProps, name: 'MuiStat' });
const { value, unit, variant, ...other } = props;
const ownerState = { ...props, variant };
return (
<StatRoot ref={ref} ownerState={ownerState} {...other}>
<StatValue ownerState={ownerState}>{value}</StatValue>
<StatUnit ownerState={ownerState}>{unit}</StatUnit>
</StatRoot>
);
});
마지막으로 Stat 컴포넌트를 테마 타입에 추가해줘요.
import {
ComponentsOverrides,
ComponentsVariants,
Theme as MuiTheme,
} from '@mui/material/styles';
import { StatProps } from 'path/to/Stat';
type Theme = Omit<MuiTheme, 'components'>;
declare module '@mui/material/styles' {
interface ComponentNameToClassKey {
MuiStat: 'root' | 'value' | 'unit';
}
interface ComponentsPropsList {
MuiStat: Partial<StatProps>;
}
interface Components {
MuiStat?: {
defaultProps?: ComponentsPropsList['MuiStat'];
styleOverrides?: ComponentsOverrides<Theme>['MuiStat'];
variants?: ComponentsVariants['MuiStat'];
};
}
}
템플릿 (Template)
이 템플릿은 위 단계별 가이드의 최종 결과물이에요. 내장 컴포넌트처럼 테마로 스타일링할 수 있는 커스텀 컴포넌트를 만드는 방법을 보여주죠.
import * as React from 'react';
import Stack from '@mui/material/Stack';
import { styled, useThemeProps } from '@mui/material/styles';
export interface StatProps {
value: number | string;
unit: string;
variant?: 'outlined';
}
interface StatOwnerState extends StatProps {
// …key value pairs for the internal state that you want to style the slot
// but don't want to expose to the users
}
const StatRoot = styled('div', {
name: 'MuiStat',
slot: 'root',
})<{ ownerState: StatOwnerState }>(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[2],
letterSpacing: '-0.025em',
fontWeight: 600,
variants: [
{
props: {
variant: 'outlined',
},
style: {
border: `2px solid ${theme.palette.divider}`,
boxShadow: 'none',
},
},
],
...theme.applyStyles('dark', {
backgroundColor: 'inherit',
}),
}));
const StatValue = styled('div', {
name: 'MuiStat',
slot: 'value',
})<{ ownerState: StatOwnerState }>(({ theme }) => ({
...theme.typography.h3,
}));
const StatUnit = styled('div', {
name: 'MuiStat',
slot: 'unit',
})<{ ownerState: StatOwnerState }>(({ theme }) => ({
...theme.typography.body2,
color: theme.palette.text.secondary,
...theme.applyStyles('dark', {
color: 'inherit',
}),
}));
const Stat = React.forwardRef<HTMLDivElement, StatProps>(
function Stat(inProps, ref) {
const props = useThemeProps({ props: inProps, name: 'MuiStat' });
const { value, unit, variant, ...other } = props;
const ownerState = { ...props, variant };
return (
<StatRoot ref={ref} ownerState={ownerState} {...other}>
<StatValue ownerState={ownerState}>{value}</StatValue>
<StatUnit ownerState={ownerState}>{unit}</StatUnit>
</StatRoot>
);
},
);
export default function StatFullTemplate() {
return (
<Stack direction="row" spacing={2}>
<Stat value="1.9M" unit="Favorites" />
<Stat value="5.1M" unit="Views" variant="outlined" />
</Stack>
);
}