다크 모드

다크 모드 (Dark mode)

Material UI는 라이트 테마(기본값)와 다크 테마, 두 가지 팔레트 모드를 제공합니다. 사용자 취향에 따라 다크 모드를 기본값으로 고정하거나, 시스템 설정을 따라 자동으로 전환되도록 구성할 수 있어요. 이 문서에서는 그 방법을 하나씩 살펴볼게요.

출처: 문서

본문

Material UI는 두 가지 팔레트 모드를 제공합니다. 라이트(light, 기본값)와 다크(dark)가 그것이에요.

:::success Material UI theming agent skill을 사용하면 AI 코딩 어시스턴트에게 다크 모드, 색상 스킴, SSR 동작에 관한 전체 맥락을 제공할 수 있어요. :::

다크 모드만 사용하기 (Dark mode only)

사용자의 설정과 상관없이 애플리케이션에서 다크 테마를 기본값으로 쓰게 하려면, createTheme() 헬퍼에 mode: 'dark'를 추가하면 됩니다.

import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';

const darkTheme = createTheme({
  palette: {
    mode: 'dark',
  },
});

export default function App() {
  return (
    <ThemeProvider theme={darkTheme}>
      <CssBaseline />
      <main>This app is using the dark mode</main>
    </ThemeProvider>
  );
}

createTheme() 헬퍼에 mode: 'dark'를 추가하면 아래 데모처럼 여러 팔레트 값이 바뀝니다.

import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import { styled, ThemeProvider, useTheme, createTheme } from '@mui/material/styles';

const Root = styled('div')(({ theme }) => ({
  backgroundColor: theme.palette.background.default,
  color: theme.palette.text.primary,
  padding: theme.spacing(2),
  borderRadius: 4,
  [theme.breakpoints.up('md')]: {
    padding: theme.spacing(3),
  },
}));

const Color = styled(Grid)(({ theme }) => ({
  display: 'flex',
  alignItems: 'center',
  '& div:first-of-type': {
    width: theme.spacing(5),
    height: theme.spacing(5),
    flexShrink: 0,
    marginRight: theme.spacing(1.5),
    borderRadius: theme.shape.borderRadius,
  },
}));

function Demo() {
  const theme = useTheme();

  const item = (color, name, expanded = false, border = false) => (
    <Color size={{ xs: 12, sm: 6, md: expanded ? 8 : 4 }}>
      <div
        style={{
          backgroundColor: color,
          border: border ? `1px solid ${theme.palette.divider}` : undefined,
        }}
      />
      <div>
        <Typography variant="body2">{name}</Typography>
        <Typography variant="body2" sx={{ color: 'text.secondary' }}>
          {color}
        </Typography>
      </div>
    </Color>
  );

  return (
    <Root>
      <Typography gutterBottom sx={{ mb: 1.5 }}>
        Typography
      </Typography>
      <Grid container spacing={1}>
        {item(theme.palette.text.primary, 'palette.text.primary')}
        {item(theme.palette.text.secondary, 'palette.text.secondary')}
        {item(theme.palette.text.disabled, 'palette.text.disabled')}
      </Grid>
      <Typography gutterBottom sx={{ mt: 4, mb: 1.5 }}>
        Buttons
      </Typography>
      <Grid container spacing={1}>
        {item(theme.palette.action.active, 'palette.action.active')}
        {item(theme.palette.action.hover, 'palette.action.hover')}
        {item(theme.palette.action.selected, 'palette.action.selected')}
        {item(theme.palette.action.disabled, 'palette.action.disabled')}
        {item(
          theme.palette.action.disabledBackground,
          'palette.action.disabledBackground',
          true,
        )}
      </Grid>
      <Typography gutterBottom sx={{ mt: 4, mb: 1.5 }}>
        Background
      </Typography>
      <Grid container spacing={1}>
        {item(
          theme.palette.background.default,
          'palette.background.default',
          false,
          true,
        )}
        {item(
          theme.palette.background.paper,
          'palette.background.paper',
          false,
          true,
        )}
      </Grid>
      <Typography gutterBottom sx={{ mt: 4, mb: 1.5 }}>
        Divider
      </Typography>
      <Grid container spacing={1}>
        {item(theme.palette.divider, 'palette.divider')}
      </Grid>
    </Root>
  );
}

const darkTheme = createTheme({
  palette: {
    // Switching the dark mode on is a single property value change.
    mode: 'dark',
  },
});

export default function DarkTheme() {
  return (
    <Box sx={{ width: '100%' }}>
      <ThemeProvider theme={darkTheme}>
        <Demo />
      </ThemeProvider>
    </Box>
  );
}

<ThemeProvider> 컴포넌트 안에 <CssBaseline />을 추가하면 앱의 배경도 다크 모드로 적용됩니다.

:::warning 이 방식으로 다크 모드를 설정하는 것은 기본 팔레트를 사용할 때만 동작해요. 커스텀 팔레트를 쓰고 있다면 mode에 맞는 올바른 값이 들어있는지 확인해야 합니다. 자세한 방법은 다음 섹션에서 설명할게요. :::

다크 팔레트 오버라이딩 (Overriding the dark palette)

기본 팔레트를 오버라이드하려면 hex, RGB, HSL 형식의 커스텀 색상을 가진 팔레트 객체를 제공하면 됩니다.

const darkTheme = createTheme({
  palette: {
    mode: 'dark',
    primary: {
      main: '#ff5252',
    },
  },
});

팔레트 구조에 대해 더 알아보려면 Palette 문서를 참고하세요.

시스템 환경설정 (System preference)

일부 사용자는 운영체제에서 시스템 전체 또는 사용자 에이전트별로 라이트/다크 모드 환경설정을 지정해두기도 해요. 아래 섹션에서는 이 환경설정을 앱 테마에 반영하는 방법을 설명할게요.

기본 제공 지원 (Built-in support)

colorSchemes 노드를 사용하면 여러 색상 스킴을 가진 애플리케이션을 만들 수 있어요. 기본 제공 색상 스킴은 light와 dark이고, 값을 true로 설정하면 활성화됩니다.

라이트 색상 스킴은 기본적으로 활성화되어 있으므로, 다크 색상 스킴만 설정해주면 됩니다.

import { ThemeProvider, createTheme } from '@mui/material/styles';

const theme = createTheme({
  colorSchemes: {
    dark: true,
  },
});

function App() {
  return <ThemeProvider theme={theme}>...</ThemeProvider>;
}

colorSchemes를 제공하면 다음 기능들이 활성화됩니다.

  • 사용자 환경설정에 따라 라이트/다크 색상 스킴이 자동으로 전환됨
  • 창(탭) 간 동기화 — 한 탭에서 색상 스킴을 바꾸면 다른 모든 탭에도 적용됨
  • 색상 스킴 변경 시 트랜지션 비활성화 옵션

:::info colorSchemes API는 이전의 제한적인 palette API를 개선한 버전이에요. 위 기능들은 colorSchemes API에서만 사용할 수 있으므로, palette API보다는 이 API 사용을 권장합니다. colorSchemes와 palette가 모두 제공되면 palette가 우선합니다. :::

:::success 시스템 환경설정 기능을 테스트해보려면 CSS 미디어 기능 prefers-color-scheme 에뮬레이션 가이드를 따라해보세요. :::

prefers-color-scheme 미디어 접근 (Accessing media prefers-color-scheme)

이 환경설정은 useMediaQuery 훅과 prefers-color-scheme 미디어 쿼리를 이용해 사용할 수 있어요.

아래 데모는 사용자의 OS나 브라우저 설정에서 그 환경설정을 확인하는 방법을 보여줍니다.

import * as React from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';

function App() {
  const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
  return <div>prefersDarkMode: {prefersDarkMode.toString()}</div>;
}

색상 모드 토글 (Toggling color mode)

기본 제공 지원 환경에서 사용자가 모드를 직접 토글할 수 있게 하려면, useColorScheme 훅을 사용해 모드를 읽고 업데이트하면 됩니다.

:::info mode는 첫 렌더링에서 항상 undefined예요. 그래서 아래 데모처럼 이 경우를 반드시 처리해줘야 합니다. 그렇지 않으면 hydration mismatch 오류가 발생할 수 있어요. :::

import Box from '@mui/material/Box';
import RadioGroup from '@mui/material/RadioGroup';
import Radio from '@mui/material/Radio';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormLabel from '@mui/material/FormLabel';
import { ThemeProvider, createTheme, useColorScheme } from '@mui/material/styles';

function MyApp() {
  const { mode, setMode } = useColorScheme();
  if (!mode) {
    return null;
  }
  return (
    <Box
      sx={{
        display: 'flex',
        width: '100%',
        alignItems: 'center',
        justifyContent: 'center',
        bgcolor: 'background.default',
        color: 'text.primary',
        borderRadius: 1,
        p: 3,
        minHeight: '56px',
      }}
    >
      <FormControl>
        <FormLabel id="demo-theme-toggle">Theme</FormLabel>
        <RadioGroup
          aria-labelledby="demo-theme-toggle"
          name="theme-toggle"
          row
          value={mode}
          onChange={(event) =>
            setMode(event.target.value as 'system' | 'light' | 'dark')
          }
        >
          <FormControlLabel value="system" control={<Radio />} label="System" />
          <FormControlLabel value="light" control={<Radio />} label="Light" />
          <FormControlLabel value="dark" control={<Radio />} label="Dark" />
        </RadioGroup>
      </FormControl>
    </Box>
  );
}

const theme = createTheme({
  colorSchemes: {
    dark: true,
  },
});

export default function ToggleColorMode() {
  return (
    <ThemeProvider theme={theme}>
      <MyApp />
    </ThemeProvider>
  );
}

스토리지 매니저 (Storage manager)

기본적으로 색상 스킴의 기본 제공 지원은 브라우저의 localStorage API를 사용해 사용자의 모드와 스킴 환경설정을 저장합니다.

다른 스토리지 매니저를 사용하려면 이 시그니처를 가진 커스텀 함수를 만들면 됩니다.

type Unsubscribe = () => void;

function storageManager(params: { key: string }): {
  get: (defaultValue: any) => any;
  set: (value: any) => void;
  subscribe: (handler: (value: any) => void) => Unsubscribe;
};

그다음 ThemeProvider 컴포넌트의 storageManager prop에 전달하세요.

import { ThemeProvider, createTheme } from '@mui/material/styles';
import type { StorageManager } from '@mui/material/styles';

const theme = createTheme({
  colorSchemes: {
    dark: true,
  },
});

function storageManager(params): StorageManager {
  return {
    get: (defaultValue) => {
      // Your implementation
    },
    set: (value) => {
      // Your implementation
    },
    subscribe: (handler) => {
      // Your implementation
      return () => {
        // cleanup
      };
    },
  };
}

function App() {
  return (
    <ThemeProvider theme={theme} storageManager={storageManager}>
      ...
    </ThemeProvider>
  );
}

:::warning InitColorSchemeScript 컴포넌트를 사용해 SSR 깜빡임(플리커)을 방지하려면, 커스텀 스토리지 매니저에 localStorage 구현을 반드시 포함해야 합니다. :::

스토리지 비활성화 (Disable storage)

스토리지 매니저를 비활성화하려면 storageManager prop에 null을 전달하면 됩니다.

<ThemeProvider theme={theme} storageManager={null}>
  ...
</ThemeProvider>

:::warning 스토리지 매니저를 비활성화하면 사용자가 페이지를 새로고침할 때마다 앱이 기본 모드로 리셋됩니다. :::

트랜지션 비활성화 (Disable transitions)

트랜지션 없이 색상 스킴을 즉시 전환하려면 ThemeProvider 컴포넌트에 disableTransitionOnChange prop을 적용하세요.

<ThemeProvider theme={theme} disableTransitionOnChange>
  ...
</ThemeProvider>

이중 렌더링 비활성화 (Disable double rendering)

기본적으로 ThemeProvider는 테마에 라이트 그리고 다크 색상 스킴이 포함되어 있으면 SSR hydration 불일치를 방지하기 위해 다시 렌더링합니다.

이 동작을 비활성화하려면 noSsr prop을 사용하세요.

<ThemeProvider theme={theme} noSsr>

noSsr은 다음과 같은 경우에 유용합니다.

  • 클라이언트 전용 애플리케이션, 예를 들어 단일 페이지 애플리케이션(SPA). 이 prop은 성능을 최적화하고 사용자가 페이지를 새로고침할 때 다크 모드 깜빡임을 방지해줍니다.
  • Suspense를 사용하는 서버 렌더링 애플리케이션. 다만 서버 렌더 출력이 클라이언트의 초기 렌더 출력과 일치하는지 확인해야 합니다.

기본 모드 설정 (Setting the default mode)

colorSchemes를 제공하면 기본 모드는 system이 됩니다. 즉 사용자가 사이트를 처음 방문할 때 시스템 환경설정을 따른다는 뜻이에요.

다른 기본 모드를 설정하려면 ThemeProvider 컴포넌트에 defaultMode prop을 전달하세요.

<ThemeProvider theme={theme} defaultMode="dark">

:::info defaultMode 값은 'light', 'dark', 'system'이 될 수 있어요. :::

InitColorSchemeScript 컴포넌트

InitColorSchemeScript 컴포넌트를 사용해 SSR 플리커를 방지한다면, ThemeProvider 컴포넌트에 전달한 것과 같은 값으로 defaultMode를 설정해야 합니다.

<InitColorSchemeScript defaultMode="dark">

다크 모드에서 스타일링 (Styling in dark mode)

특정 모드에 대한 스타일을 적용하려면 theme.applyStyles() 유틸리티를 사용하세요.

스타일을 전환할 때 theme.palette.mode를 확인하는 것보다 이 함수를 사용하는 것을 권장합니다. 이점이 더 많기 때문이에요.

  • 자체 제작 zero-runtime CSS-in-JS 솔루션인 Pigment CSS와 함께 사용할 수 있어요.
  • 일반적으로 가독성과 유지보수성이 더 좋습니다.
  • 스타일 재계산이 필요 없어 약간 더 성능이 좋지만, SSR 생성 스타일의 번들 크기는 더 큽니다.

사용법 (Usage)

styled 함수와 함께:

import { styled } from '@mui/material/styles';

const MyComponent = styled('div')(({ theme }) => [
  {
    color: '#fff',
    backgroundColor: theme.palette.primary.main,
    '&:hover': {
      boxShadow: theme.shadows[3],
      backgroundColor: theme.palette.primary.dark,
    },
  },
  theme.applyStyles('dark', {
    backgroundColor: theme.palette.secondary.main,
    '&:hover': {
      backgroundColor: theme.palette.secondary.dark,
    },
  }),
]);

sx prop과 함께:

import Button from '@mui/material/Button';

<Button
  sx={[
    (theme) => ({
      color: '#fff',
      backgroundColor: theme.palette.primary.main,
      '&:hover': {
        boxShadow: theme.shadows[3],
        backgroundColor: theme.palette.primary.dark,
      },
    }),
    (theme) =>
      theme.applyStyles('dark', {
        backgroundColor: theme.palette.secondary.main,
        '&:hover': {
          backgroundColor: theme.palette.secondary.dark,
        },
      }),
  ]}
>
  Submit
</Button>;

:::warning cssVariables: true일 때 theme.applyStyles()로 적용한 스타일은 그 밖에 정의된 스타일보다 특이도(specificity)가 더 높아요. 그래서 스타일을 오버라이드해야 한다면 아래처럼 반드시 theme.applyStyles()도 함께 사용해야 합니다.

const BaseButton = styled('button')(({ theme }) =>
  theme.applyStyles('dark', {
    backgroundColor: 'white',
  }),
);

const AliceblueButton = styled(BaseButton)({
  backgroundColor: 'aliceblue', // In dark mode, backgroundColor will be white as theme.applyStyles() has higher specificity
});

const PinkButton = styled(BaseButton)(({ theme }) =>
  theme.applyStyles('dark', {
    backgroundColor: 'pink', // In dark mode, backgroundColor will be pink
  }),
);

:::

API

theme.applyStyles(mode, styles) => CSSObject

특정 모드에 대한 스타일을 적용합니다.

인자 (Arguments)

  • mode ('light' | 'dark') - 스타일을 적용할 모드입니다.
  • styles (CSSObject) - 지정한 모드에 적용할 스타일을 담은 객체입니다.

applyStyles 오버라이딩 (Overriding applyStyles)

theme.applyStyles()를 커스텀 함수로 오버라이드하면 반환되는 값을 완전히 제어할 수 있어요. 오버라이드하기 전에 소스 코드를 살펴 기본 구현이 어떻게 동작하는지 이해하는 것을 권장합니다. 예를 들어 템플릿 리터럴 안에서 사용할 수 있도록 함수가 객체 대신 문자열을 반환하게 하고 싶다면:

const theme = createTheme({
  cssVariables: {
    colorSchemeSelector: '.mode-%s',
  },
  colorSchemes: {
    dark: {},
    light: {},
  },
  applyStyles: function (key: string, styles: any) {
    // return a string instead of an object
    return `*:where(.mode-${key}) & {${styles}}`;
  },
});

const StyledButton = styled('button')`
  ${theme.applyStyles(
    'dark', `
      background: white;
    `
  )}
`;

코더모드 (Codemod)

theme.palette.mode를 사용하던 코드베이스를 theme.applyStyles()를 사용하도록 마이그레이션하는 codemod를 제공합니다. 아래 codemod를 각각 실행하거나 한 번에 모두 실행할 수 있어요.

npx @mui/codemod@latest v6.0.0/styled <path/to/folder-or-file>
npx @mui/codemod@latest v6.0.0/sx-prop <path/to/folder-or-file>
npx @mui/codemod@latest v6.0.0/theme-v6 <path/to/theme-file>

커스텀 styleOverrides가 들어있는 파일에 대해서는 v6.0.0/theme-v6를 실행하세요. 커스텀 테마가 없으면 이 codemod는 무시해도 됩니다.

다크 모드 깜빡임 (Dark mode flicker)

문제 (The problem)

서버 렌더링 애플리케이션은 사용자의 기기에 도달하기 전에 빌드됩니다. 그래서 처음 로드될 때 사용자가 선호하는 색상 스킴에 자동으로 맞출 수 없어요.

보통 이런 일이 발생합니다.

  1. 앱을 로드하고 다크 모드로 설정합니다.
  2. 페이지를 새로고침합니다.
  3. 앱이 잠시 라이트 모드(기본값)로 나타납니다.
  4. 앱이 완전히 로드되면 다시 다크 모드로 전환됩니다.

브라우저가 다크 모드 환경설정을 기억하고 있는 한, 이 라이트 모드의 "섬광"이 앱을 열 때마다 발생합니다.

이 갑작스러운 변화는 특히 어두운 환경에서 눈에 거슬릴 수 있어요. 눈의 피로를 유발하고, 특히 이 전환 중에 앱과 상호작용한다면 경험을 방해할 수 있습니다.

이 문제를 더 잘 이해하려면 아래 애니메이션 이미지를 확인해보세요.

An example video that shows a page that initially loads correctly in dark mode but quickly flickers to light mode.

해결책: CSS 변수 (The solution: CSS variables)

이 문제를 해결하려면 스타일링과 테마에 대한 새로운 접근 방식이 필요합니다. (이 기능의 구현에 대해 더 알고 싶다면 이 CSS 변수 지원 RFC를 참고하세요.)

CSS 미디어 prefers-color-scheme를 이용해 라이트/다크 모드를 지원해야 하는 애플리케이션의 경우, CSS 변수 기능을 활성화하면 이 문제가 해결됩니다.

하지만 모드를 수동으로 토글할 수 있어야 한다면, 플리커를 피하려면 CSS 변수와 InitColorSchemeScript 컴포넌트를 함께 사용해야 합니다. 자세한 내용은 SSR 플리커 방지 섹션을 확인하세요.

더 알아보기 (Learn more)

  • Material UI 테마 커스터마이징