테마가 적용되는 컴포넌트 만들기

테마가 적용되는 컴포넌트 만들기 (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>
  );
}

더 알아보기 (Learn more)