Create custom components

Create custom components (커스텀 컴포넌트 만들기)

Mantine의 테마링, 스타일링, 그리고 다른 핵심 기능과 통합되는 커스텀 컴포넌트를 만드는 방법을 다뤄요. Factory, factory, useProps, useStyles, varsResolver 같은 Mantine 내부 API를 하나씩 설명해 드릴게요.

출처: 문서

본문

이 가이드에서는 ExampleComponent를 예시로 사용할게요:

import {
  Box,
  BoxProps,
  createVarsResolver,
  ElementProps,
  factory,
  Factory,
  getRadius,
  MantineRadius,
  StylesApiProps,
  useProps,
  useStyles,
} from '@mantine/core';
import classes from './ExampleComponent.module.css';

export type ExampleComponentStylesNames = 'root' | 'inner';
export type ExampleComponentVariant = 'filled' | 'outline';
export type ExampleComponentCssVariables = {
  root: '--radius';
};

export interface ExampleComponentProps
  extends BoxProps, StylesApiProps<ExampleComponentFactory>, ElementProps<'div'> {
  /** Component border-radius */
  radius: MantineRadius;
}

export type ExampleComponentFactory = Factory<{
  props: ExampleComponentProps;
  ref: HTMLDivElement;
  stylesNames: ExampleComponentStylesNames;
  vars: ExampleComponentCssVariables;
  variant: ExampleComponentVariant;
}>;

const defaultProps = {
  radius: 'md',
} satisfies Partial<ExampleComponentProps>;

const varsResolver = createVarsResolver<ExampleComponentFactory>((_theme, { radius }) => ({
  root: {
    '--radius': getRadius(radius),
  },
}));

export const ExampleComponent = factory<ExampleComponentFactory>((_props) => {
  const props = useProps('ExampleComponent', defaultProps, _props);
  const {
    classNames,
    className,
    style,
    styles,
    unstyled,
    vars,
    attributes,
    radius,
    children,
    ...others
  } = props;

  const getStyles = useStyles<ExampleComponentFactory>({
    name: 'ExampleComponent',
    classes,
    props,
    className,
    style,
    classNames,
    styles,
    unstyled,
    attributes,
    vars,
    varsResolver,
  });

  return (
    <Box {...getStyles('root')} {...others}>
      <div {...getStyles('inner')}>{children}</div>
    </Box>
  );
});

ExampleComponent.displayName = 'ExampleComponent';
ExampleComponent.classes = classes;

Factory 타입

Factory 타입은 컴포넌트와 관련된 모든 타입(variant, Styles API 선택자, ref 타입, CSS 변수와 이후에 설명할 다른 속성들)을 그룹화하는 데 사용돼요. props를 제외한 모든 속성은 선택이에요:

// Usage with Styles API when the component has related styles
export type ExampleComponentFactory = Factory<{
  props: ExampleComponentProps;
  ref: HTMLDivElement;
  stylesNames: ExampleComponentStylesNames;
  vars: ExampleComponentCssVariables;
  variant: ExampleComponentVariant;
}>;

// Component has no styles or does not expose Styles API features
export type ExampleComponentFactory = Factory<{
  props: ExampleComponentProps;
  ref: HTMLDivElement;
}>;

만들어진 ExampleComponentFactory는 위 예시에서 @mantine/core 패키지에서 import한 모든 헬퍼 함수(useStyles, createVarsResolver, factory)의 첫 번째 타입 인자로 전달돼요.

Factory 타입은 검증과 IDE 자동완성에 사용돼요. 전달된 타입을 수정하지 않아요:

export type ExampleComponentFactory = {
  props: ExampleComponentProps;
  ref: HTMLDivElement;
};

// Both examples are the same, Factory only used for validation, it can be omitted
export type ExampleComponentFactory = Factory<{
  props: ExampleComponentProps;
  ref: HTMLDivElement;
}>;

factory 함수

factory 함수는 props를 타이핑하고 extend와 withProps 같은 공유 정적 속성을 할당하는 데 사용돼요:

export const ExampleComponent = factory<ExampleComponentFactory>((_props) => {
  // ... component body
});

// Optionally, you can assign displayName and classes
ExampleComponent.displayName = 'ExampleComponent';
ExampleComponent.classes = classes;

Box 컴포넌트

Box 컴포넌트는 다른 모든 컴포넌트의 기반이에요. 커스텀 컴포넌트를 만들 때 루트 요소로 사용하고, style props를 지원하려면 ...others props를 스프레드해요.

컴포넌트에 style props 타입을 추가하려면 BoxProps를 확장해요:

// Extend props with `BoxProps` to add style props types
export interface ExampleComponentProps
  extends BoxProps, StylesApiProps<ExampleComponentFactory>, ElementProps<'div'> {
}

export const ExampleComponent = factory<ExampleComponentFactory>((_props) => {
  const props = useProps('ExampleComponent', defaultProps, _props);
  const {
    classNames,
    className,
    style,
    styles,
    unstyled,
    vars,
    attributes,
    radius,
    children,
    ...others
  } = props;

  // Spread ...others props to the Box component to support style props
  return (
    <Box {...others}>{children}</Box>
  );
});

ElementProps 타입

ElementProps는 컴포넌트가 받는 props를 가져오는 데 사용돼요. DOM 요소('div', 'span' 등)를 나타내는 문자열이거나 React 컴포넌트 타입을 전달할 수 있어요. 두 번째 타입 인자는 선택이며, 원본 컴포넌트/요소에서 props 타입을 생략하는 데 사용할 수 있어요.

ElementProps는 style prop 시그니처를 Mantine 컴포넌트와 호환되고 CSS 변수를 사용할 수 있도록 재할당해요.

ElementProps 타입 사용 예시:

// Root element is `div`, extend component props with ElementProps<'div'>
export interface ExampleComponentProps extends ElementProps<'div'> {}

// Type conflict: `input` element has html attributes `color` and `size`,
// but we want to define our own types. To fix types conflict, use the second
// type argument with `'color' | 'size'` union to omit `color` and `size` from
// `input` html props.
export interface ExampleComponentProps extends ElementProps<'input', 'color' | 'size'> {
  color: 'blue' | 'red';
  size: 'sm' | 'lg';
}

useProps 훅

useProps 훅은 default props를 지원하는 데 사용돼요. 인자를 받아요:

  • 테마에서 컴포넌트를 참조하는 데 사용되는 컴포넌트 이름
  • 컴포넌트 레벨의 기본 props
  • 컴포넌트 props

useProps는 다음 순서로 props를 병합해요:

  • 컴포넌트 props — 최우선순위
  • 테마의 default props — 낮은 우선순위
  • 컴포넌트 레벨에 정의된 default props — 이전 단계에서 정의되지 않은 경우에만 사용

useProps 사용 예시:

const defaultProps = {
  radius: 'md',
} satisfies Partial<ExampleComponentProps>;

export const ExampleComponent = factory<ExampleComponentFactory>((_props) => {
  const props = useProps('ExampleComponent', defaultProps, _props);
  // Extract individual props only after processing with useProps
  const {
    classNames,
    className,
    style,
    styles,
    unstyled,
    vars,
    attributes,
    radius,
    children,
    ...others
  } = props;

  // ... component body
});

useProps에 전달하는 defaultProps는 props를 올바르게 타이핑하기 위해 satisfies Partial 타입 단언을 사용해야 해요:

export interface ExampleComponentProps
  extends BoxProps, StylesApiProps<ExampleComponentFactory>, ElementProps<'div'> {
  /** Component border-radius */
  radius?: MantineRadius;
}

// ✅ useProps can infer types correctly
// `radius` prop is `MantineRadius`
const defaultProps = {
  radius: 'md',
} satisfies Partial<ExampleComponentProps>;

// ❌ useProps cannot infer types correctly
// `radius` prop is `MantineRadius | undefined`
const defaultProps: Partial<ExampleComponentProps> = {
  radius: 'md',
};

defaultProps를 다음과 같이 사용할 수 있어요:

import { MantineProvider, Button, Group, createTheme } from '@mantine/core';
import { ExampleComponent } from './ExampleComponent';

const theme = createTheme({
  components: {
    ExampleComponent: ExampleComponent.extend({
      defaultProps: {
        radius: 'sm',
      },
    }),
  },
});

useStyles 훅

useStyles 훅은 Styles API 기능들(classNames, styles, attributes와 관련 속성들)을 지원하는 데 사용돼요.

useStyles는 getStyles 함수를 반환하고, 이 함수는 요소에 스프레드({...getStyles('root')})해야 하는 객체를 반환해요:

// 🔝 See full component code above
const getStyles = useStyles<ExampleComponentFactory>({
  // Component name, used to generate static selectors (.mantine-ExampleComponent-root)
  // and for `classNames`, `styles` support in theme object
  name: 'ExampleComponent',

  // CSS modules classes, usually imported from `*.module.css` file directly
  classes,

  // Component props returned from `useProps` hook,
  // used for resolving `classNames` and `styles` with callback function notation
  props,

  // Element that must have `className` and `style` passed to the component
  // optional, `root` is the default value
  rootSelector: 'root',

  // className and style are added to the root element (rootSelector)
  className,
  style,

  // classNames, attributes and styles are resolved automatically by useStyles hook
  classNames,
  attributes,
  styles,

  // `getStyles` omits all styles if unstyled is set
  unstyled,

  // CSS variables resolver, defined in component file, described later
  varsResolver,

  // CSS variables resolved override, defined in user application
  vars,
});

getStyles 함수

getStyles 함수는 useStyles 훅이 반환해요. 첫 번째 인자는 Styles API 선택자, 두 번째 인자는 반환 객체에 className이나 style을 추가하는 데 사용할 수 있어요:

<Box {...getStyles('root')}>
  <div {...getStyles('inner', { className: 'custom-class', style: { color: 'red' } })}>
    {children}
  </div>
</Box>

varsResolver

varsResolver를 사용해 컴포넌트 props를 CSS 변수로 변환해요.

Button 컴포넌트에서의 varsResolver 사용 예시:

import { getFontSize, getSize, createVarsResolver } from '@mantine/core';

const varsResolver = createVarsResolver<ButtonFactory>(
  (theme, { radius, color, gradient, variant, size, justify, autoContrast }) => {
    const colors = theme.variantColorResolver({
      color: color || theme.primaryColor,
      theme,
      gradient,
      variant: variant || 'filled',
      autoContrast,
    });

    return {
      root: {
        '--button-justify': justify,
        '--button-height': getSize(size, 'button-height'),
        '--button-padding-x': getSize(size, 'button-padding-x'),
        '--button-fz': size?.includes('compact')
          ? getFontSize(size.replace('compact-', ''))
          : getFontSize(size),
        '--button-radius': radius === undefined ? undefined : getRadius(radius),
        '--button-bg': color || variant ? colors.background : undefined,
        '--button-hover': color || variant ? colors.hover : undefined,
        '--button-color': colors.color,
        '--button-bd': color || variant ? colors.border : undefined,
        '--button-hover-color': color || variant ? colors.hoverColor : undefined,
      },
    };
  }
);

복합 컴포넌트 (Compound components)

복합 컴포넌트(Button.Group, Input.Wrapper 등)는 메인 컴포넌트의 정적 속성으로 정의되고 메인 컴포넌트 팩토리에서 타입으로 할당돼요.

Tabs 컴포넌트에서 복합 컴포넌트를 할당하는 예시:

export type TabsFactory = Factory<{
  props: TabsProps;
  ref: HTMLDivElement;
  variant: TabsVariant;
  stylesNames: TabsStylesNames;
  vars: TabsCssVariables;

  // Set compound components types
  staticComponents: {
    Tab: typeof TabsTab;
    Panel: typeof TabsPanel;
    List: typeof TabsList;
  };
}>;

export const Tabs = factory<TabsFactory>((_props) => {
  // ... component body
});

// Assign compound components
Tabs.Tab = TabsTab;
Tabs.Panel = TabsPanel;
Tabs.List = TabsList;

Namespace 내보내기

Mantine 컴포넌트는 관련 타입을 컴포넌트와 함께 그룹화하는 namespace 내보내기를 지원해요. 예를 들어 Button 컴포넌트는 관련 타입을 Button.*로 내보내요:

import { Button } from '@mantine/core';

// Props type, does not require separate import
type Props = Button.Props;

이 기능을 구현하려면 컴포넌트 파일 끝이나 index.ts에 namespace 내보내기를 추가해요. Button 컴포넌트 namespace 내보내기 예시:

export namespace Button {
  export type Props = ButtonProps;
  export type StylesNames = ButtonStylesNames;
  export type CssVariables = ButtonCssVariables;
  export type Factory = ButtonFactory;
  export type Variant = ButtonVariant;
  export type Size = ButtonSize;

  export namespace Group {
    export type Props = ButtonGroupProps;
    export type StylesNames = ButtonGroupStylesNames;
    export type CssVariables = ButtonGroupCssVariables;
    export type Factory = ButtonGroupFactory;
  }

  export namespace GroupSection {
    export type Props = ButtonGroupSectionProps;
    export type StylesNames = ButtonGroupSectionStylesNames;
    export type CssVariables = ButtonGroupSectionCssVariables;
    export type Factory = ButtonGroupSectionFactory;
  }
}

polymorphicFactory

polymorphicFactory는 다형성 컴포넌트를 만드는 데 사용돼요. 루트 요소를 바꿔야 한다면 factory 대신 polymorphicFactory를 사용하세요. 예를 들어 Button 컴포넌트는 다형성이라 기본 루트 요소가 button이지만, component와 renderRoot props로 a나 다른 요소로 바꿀 수 있어요.

polymorphicFactory는 타입에서만 동작하며, factory에 비해 컴포넌트 동작을 수정하지 않아요. polymorphicFactory로 만든 컴포넌트 타입은 TypeScript에 오버헤드를 추가하고 IDE 자동완성을 느리게 하므로, 필요할 때만 사용하세요.

완전한 다형성 컴포넌트 예시:

import {
  Box,
  BoxProps,
  createVarsResolver,
  polymorphicFactory,
  PolymorphicFactory,
  StylesApiProps,
  useProps,
  useStyles,
} from '@mantine/core';
import classes from './PolymorphicExample.module.css';

export type PolymorphicExampleStylesNames = 'root';
export type PolymorphicExampleVariant = string;
export type PolymorphicExampleCssVariables = {
  root: '--test';
};

export interface PolymorphicExampleProps
  extends BoxProps, StylesApiProps<PolymorphicExampleFactory> {}

export type PolymorphicExampleFactory = PolymorphicFactory<{
  props: PolymorphicExampleProps;
  defaultRef: HTMLDivElement;
  defaultComponent: 'div';
  stylesNames: PolymorphicExampleStylesNames;
  vars: PolymorphicExampleCssVariables;
  variant: PolymorphicExampleVariant;
}>;

const defaultProps = {} satisfies Partial<PolymorphicExampleProps>;

const varsResolver = createVarsResolver<PolymorphicExampleFactory>(() => ({
  root: {
    '--test': 'test',
  },
}));

export const PolymorphicExample = polymorphicFactory<PolymorphicExampleFactory>((_props) => {
  const props = useProps('PolymorphicExample', defaultProps, _props);
  const { classNames, className, style, styles, unstyled, vars, attributes, ...others } = props;

  const getStyles = useStyles<PolymorphicExampleFactory>({
    name: 'PolymorphicExample',
    props,
    classes,
    className,
    style,
    classNames,
    styles,
    unstyled,
    attributes,
    vars,
    varsResolver,
  });

  return <Box {...getStyles('root')} {...others} />;
});

PolymorphicExample.displayName = '@mantine/core/PolymorphicExample';

genericFactory

제네릭 타입 인자를 받는 컴포넌트를 만들려면 genericFactory를 사용해요. 예를 들어 Accordion 컴포넌트의 value와 onChange props 타입은 multiple prop 값에 따라 달라져요:

type AccordionValue<Multiple extends boolean> = Multiple extends true
  ? string[]
  : string | null;

// Define props interface with generic type argument
export interface AccordionProps<Multiple extends boolean = false>
  extends
    BoxProps,
    StylesApiProps<AccordionFactory>,
    ElementProps<'div', 'value' | 'defaultValue' | 'onChange'> {
  // props that depend on the generic type argument
  multiple?: Multiple;
  value?: AccordionValue<Multiple>;
  defaultValue?: AccordionValue<Multiple>;
  onChange?: (value: AccordionValue<Multiple>) => void;

  // ... other props
}
export type AccordionFactory = Factory<{
  // Signature with generic type argument
  signature: <Multiple extends boolean = false>(
    props: AccordionProps<Multiple>
  ) => React.JSX.Element;

  // other properties same as in regular factory
  props: AccordionProps;
  ref: HTMLDivElement;
  // ...
}>;

더 알아보기 (Learn more)