Development guidelines and patterns for building React component libraries with Mantine UI, TypeScript, and modern tooling...
Apply this skill when working on:
Component.Target)Components are organized in a monorepo workspace structure:
/package/src/: Main component source code
ComponentName.tsx: Main component implementationComponentName.module.css: Component-scoped stylesComponentName.context.ts: Context providers (if needed)ComponentName.errors.ts: Error messagesComponentName.test.tsx: Jest + Testing Library testsComponentName.story.tsx: Storybook storiesSubComponent/SubComponent.tsx: Sub-components in their own foldersindex.ts: Public exports/docs/: Next.js documentation site
demos/ComponentName.demo.*.tsx: Interactive demosstyles-api/ComponentName.styles-api.ts: Styles API metadatadocs.mdx: Main documentation pageRefer to existing components in ./package/src/ for implementation examples.
All components use Mantine's polymorphic factory pattern.
import { polymorphicFactory, PolymorphicFactory, useProps, useStyles, createVarsResolver } from '@mantine/core';
export type ComponentStylesNames = 'root' | 'element';
export type ComponentCssVariables = {
root: '--custom-var' | '--another-var';
};
export interface ComponentProps extends BoxProps, StylesApiProps<ComponentFactory> {
/** Prop description */
customProp?: string;
}
export type ComponentFactory = PolymorphicFactory<{
props: ComponentProps;
defaultComponent: 'div';
defaultRef: HTMLDivElement;
stylesNames: ComponentStylesNames;
vars: ComponentCssVariables;
staticComponents: {
SubComponent: typeof SubComponent;
};
}>;
const defaultProps: Partial<ComponentProps> = {
customProp: 'default',
};
const varsResolver = createVarsResolver<ComponentFactory>((_, { customProp }) => ({
root: {
'--custom-var': customProp,
},
}));
export const Component = polymorphicFactory<ComponentFactory>((_props, ref) => {
const props = useProps('Component', defaultProps, _props);
const { classNames, className, style, styles, unstyled, vars, customProp, ...others } = props;
const getStyles = useStyles<ComponentFactory>({
name: 'Component',
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
varsResolver,
});
return (
<Box ref={ref} {...getStyles('root')} {...others}>
{/* component content */}
</Box>
);
});
Component.displayName = '@your-scope/Component';
Component.SubComponent = SubComponent;
Key Requirements:
unknown for unconstrained generics; narrow with type guardsany; use React.ReactNode for childrenuseProps hook for default prop mergingvarsResolver for CSS custom propertiesFor components requiring state sharing, use Mantine's safe context pattern.
import { createSafeContext } from '@mantine/core';
import { COMPONENT_ERRORS } from './Component.errors';
interface ComponentContext {
state: boolean;
setState: (value: boolean) => void;
}
export const [ComponentContextProvider, useComponentContext] = createSafeContext<ComponentContext>(
COMPONENT_ERRORS.context
);
Error Definitions in Component.errors.ts:
export const COMPONENT_ERRORS = {
context: 'Component was not found in the tree',
validation: 'Specific validation message',
};
Sub-components access parent context and enforce constraints.
import { forwardRef, useProps, isElement, createEventHandler } from '@mantine/core';
import { useComponentContext } from '../Component.context';
export interface SubComponentProps {
children: React.ReactNode;
refProp?: string;
}
export const SubComponent = forwardRef<HTMLElement, SubComponentProps>((props, ref) => {
const { children, ...others } = useProps('SubComponent', {}, props);
if (!isElement(children)) {
throw new Error(COMPONENT_ERRORS.children);
}
const ctx = useComponentContext();
const onClick = createEventHandler(children.props.onClick, () => ctx.toggle());
return cloneElement(children, { onClick, ref });
});
Every component exposes a Styles API for customization. Define in ./docs/styles-api/Component.styles-api.ts:
import type { ComponentFactory } from '@your-scope/component';
import type { StylesApiData } from '../components/styles-api.types';
export const ComponentStylesApi: StylesApiData<ComponentFactory> = {
selectors: {
root: 'Root element',
element: 'Specific child element',
},
vars: {
root: {
'--custom-var': 'Controls custom behavior',
'--another-var': 'Controls another aspect',
},
},
modifiers: [
{ modifier: 'data-active', selector: 'root', condition: '`active` prop is set' }
],
};
CSS Module (Component.module.css):
.root {
/* Use CSS custom properties from varsResolver */
property: var(--custom-var, fallback);
}
.element {
/* Scoped class name */
}
/* Data attribute modifiers */
.root[data-active] {
/* Active state */
}
Use @mantine/hooks useUncontrolled for dual-mode state:
import { useUncontrolled } from '@mantine/hooks';
const [value, setValue] = useUncontrolled({
value: props.value,
defaultValue: props.defaultValue,
finalValue: undefined,
onChange: props.onChange,
});
Use useDidUpdate from @mantine/hooks for effect-on-update patterns:
import { useDidUpdate } from '@mantine/hooks';
useDidUpdate(() => {
props.onStateChange?.(state);
}, [state]);
Validate props at runtime when type system isn't enough:
if (React.Children.count(children) !== 2) {
throw new Error('Component requires exactly two children');
}
Always support ref forwarding for component composition:
export const Component = polymorphicFactory<ComponentFactory>((_props, ref) => {
return <Box ref={ref} {...others} />;
});
Prettier auto-sorts imports per ./.prettierrc.mjs:
@mantine/* packagesRun npm run prettier:write before committing.
ESLint config extends eslint-config-mantine:
import mantine from 'eslint-config-mantine';
import tseslint from 'typescript-eslint';
export default tseslint.config(...mantine, {
ignores: ['**/.next/**', '**/*.{mjs,cjs,js,d.ts,d.mts}']
});
Run npm run lint to check all rules.
Primary config (./tsconfig.json):
Build config (tsconfig.build.json) isolates compilation scope.
Use @mantine-tests/core renderer with Testing Library.
import React from 'react';
import { render } from '@mantine-tests/core';
import { Component } from './Component';
describe('Component', () => {
it('renders without crashing', () => {
const { container } = render(<Component>Content</Component>);
expect(container).toBeTruthy();
});
it('applies custom className', () => {
const { container } = render(<Component className="custom" />);
expect(container.querySelector('.custom')).toBeTruthy();
});
it('forwards ref', () => {
const ref = React.createRef<HTMLDivElement>();
render(<Component ref={ref} />);
expect(ref.current).toBeTruthy();
});
});
Run tests with npm run jest.
Create interactive demos in ./docs/demos/:
// Component.demo.basic.tsx
import { Component } from '@your-scope/component';
import { MantineDemo } from '@docs/components';
const code = `
import { Component } from '@your-scope/component';
function Demo() {
return <Component>Content</Component>;
}
`;
export const basic: MantineDemo = {
type: 'code',
component: Demo,
code,
};
Configurator demos use MantineDemo type 'configurator' with controls object.
Main docs page at ./docs/docs.mdx:
import { InstallScript } from './components/InstallScript/InstallScript';
import * as demos from './demos';
## Installation
<InstallScript packages="@your-scope/component" />
## Usage
<Demo data={demos.basic} />
## Props
<PropsTable component="Component" />
## Styles API
<StylesApiTable component="Component" />
Refer to existing documentation structure in /docs for consistency.
<button>, <nav>, etc.) over <div> with rolestabIndex={-1} for programmatic focus managementimport { useFocusTrap } from '@mantine/hooks';
const focusTrapRef = useFocusTrap(active);
Expose component state via data attributes for CSS and screen readers:
<div data-active={isActive} data-disabled={disabled} />
.env files in .gitignore@mantine/core and @mantine/hooks versions in sync (see ./package/package.json)peerDependencies for React and Mantine packagesnpm run syncpack to verify version consistencycreateSafeContext to enforce provider boundariespackage.json exports field for correct entry pointstsconfig.json paths if using aliaseshash-css-selector (see ./rollup.config.mjs)import classes from './Component.module.css'getStyles helper for className mergingdiv; override with component propdefaultRef in factory definitionBoxProps & ComponentProps & { component?: any }getByRole over getByTestIdscreen for global queries or container for scopedwindow.matchMedia if needed (see ./jsdom.mocks.cjs)Component.story.tsx alongside componentMeta and StoryObj typesMaintain consistent file structure per component:
package/src/
āāā Component.tsx # Main component
āāā Component.module.css # Scoped styles
āāā Component.context.ts # Context (if needed)
āāā Component.errors.ts # Error constants
āāā Component.test.tsx # Tests
āāā Component.story.tsx # Storybook
āāā SubComponent/
ā āāā SubComponent.tsx # Sub-component
āāā index.ts # Public API
Public API (index.ts):
export { Component } from './Component';
export type { ComponentProps, ComponentFactory } from './Component';
npm run test before committing (includes prettier, typecheck, lint, jest)fix: resolve prop merging issue or feat: add keyboard navigationdist/, .next/, out/).gitignore to exclude build artifactsSee ./references/ for indexed documentation pointers.