Mantine's internal styling
case study: Mantine's implementation of Button
The Button source code includes the typescript file (Button.tsx) and the stylesheet (Button.module.css).
HTML element structure
There are several, nested elements:
- the root button is the top level container.
- the inner span is an intermediate container for the label and the sections
- a section is a container for an icon.
- the label span contains the button's text.
- the loader span is an intermediate container for a loader.
/* root */
<button>
{/* 1.0 loader container*/}
<span />
{/* 2.0 inner */}
<span>
{/* 2.1 section */}
<span>
<svg /> {/* 2.1.0 icon */}
</span>
{/* 2.2 label */}
<span>{/* 2.2.0 text */}</span>
</span>
</button>
Both the mantine-implemented internal classes and empty classes are present, on each inner-element:
<button class="m-77c9d27d mantine-Button-root .." ..>
<span class="m-80f1301b mantine-Button-inner">
<span class="m-811560b9 mantine-Button-label">
Save
</span>
</span>
</button>
some inner-elements have some data-attributes, which may be shared across inner-elements of distinct components, such as the mantine-active and mantine-focus-auto attributes.
For example, mantine-active allows to receive style that activates when the element is active. The attribute is always there, and the stylesheet discriminates to the :active state.
mantine-active:active {
transform: translateY(calc(0.0625rem));
}
Button's stylesheet
The stylesheet is a CSS module and follows the pattern where classes are named after inner-elements of the Button component. We reproduce the root class.
.root {
--button-height-xs: 30px;
--button-height-sm: 36px;
--button-height-md: 42px;
--button-height-lg: 50px;
--button-height-xl: 60px;
--button-height-compact-xs: 22px;
--button-height-compact-sm: 26px;
--button-height-compact-md: 30px;
--button-height-compact-lg: 34px;
--button-height-compact-xl: 40px;
--button-padding-x-xs: 14px;
--button-padding-x-sm: 18px;
--button-padding-x-md: 22px;
--button-padding-x-lg: 26px;
--button-padding-x-xl: 32px;
--button-padding-x-compact-xs: 7px;
--button-padding-x-compact-sm: 8px;
--button-padding-x-compact-md: 10px;
--button-padding-x-compact-lg: 12px;
--button-padding-x-compact-xl: 14px;
--button-height: var(--button-height-sm);
--button-padding-x: var(--button-padding-x-sm);
--button-color: var(--mantine-color-white);
user-select: none;
font-weight: 600;
position: relative;
line-height: 1;
text-align: center;
overflow: hidden;
width: auto;
cursor: pointer;
display: inline-block;
border-radius: var(--button-radius, var(--mantine-radius-default));
font-size: var(--button-fz, var(--mantine-font-size-sm));
background: var(--button-bg, var(--mantine-primary-color-filled));
border: var(--button-bd, rem(1px) solid transparent);
color: var(--button-color, var(--mantine-color-white));
height: var(--button-height, var(--button-height-sm));
padding-inline: var(--button-padding-x, var(--button-padding-x-sm));
vertical-align: middle;
&:where([data-block]) {
display: block;
width: 100%;
}
&:where([data-with-left-section]) {
padding-inline-start: calc(var(--button-padding-x) / 1.5);
}
&:where([data-with-right-section]) {
padding-inline-end: calc(var(--button-padding-x) / 1.5);
}
&:where(:disabled:not([data-loading]), [data-disabled]:not([data-loading])) {
cursor: not-allowed;
border: 1px solid transparent;
transform: none;
@mixin where-light {
color: var(--mantine-color-gray-5);
background: var(--mantine-color-gray-1);
}
@mixin where-dark {
color: var(--mantine-color-dark-3);
background: var(--mantine-color-dark-6);
}
}
&::before {
content: "";
pointer-events: none;
position: absolute;
inset: -1px;
border-radius: var(--button-radius, var(--mantine-radius-default));
transform: translateY(-100%);
opacity: 0;
filter: blur(12px);
transition: transform 150ms ease, opacity 100ms ease;
@mixin where-light {
background-color: rgba(255, 255, 255, 0.15);
}
@mixin where-dark {
background-color: rgba(0, 0, 0, 0.15);
}
}
&:where([data-loading]) {
cursor: not-allowed;
transform: none;
&::before {
transform: translateY(0);
opacity: 1;
}
& .inner {
opacity: 0;
transform: translateY(100%);
}
}
@mixin hover {
&:where(:not([data-loading], :disabled, [data-disabled])) {
background-color: var(--button-hover, var(--mantine-primary-color-filled-hover));
color: var(--button-hover-color, var(--button-color));
}
}
}
For reference, we also reproduce the typescript file. It imports the CSS module.
import {
Box,
BoxProps,
createVarsResolver,
getFontSize,
getRadius,
getSize,
MantineColor,
MantineGradient,
MantineRadius,
MantineSize,
polymorphicFactory,
PolymorphicFactory,
rem,
StylesApiProps,
useProps,
useStyles,
} from "../../core"
import { Loader, LoaderProps } from "../Loader"
import { MantineTransition, Transition } from "../Transition"
import { UnstyledButton } from "../UnstyledButton"
import { ButtonGroup } from "./ButtonGroup/ButtonGroup"
import { ButtonGroupSection } from "./ButtonGroupSection/ButtonGroupSection"
import classes from "./Button.module.css"
export type ButtonStylesNames = "root" | "inner" | "loader" | "section" | "label"
export type ButtonVariant =
| "filled"
| "light"
| "outline"
| "transparent"
| "white"
| "subtle"
| "default"
| "gradient"
export type ButtonCssVariables = {
root:
| "--button-justify"
| "--button-height"
| "--button-padding-x"
| "--button-fz"
| "--button-radius"
| "--button-bg"
| "--button-hover"
| "--button-hover-color"
| "--button-color"
| "--button-bd"
}
export interface ButtonProps extends BoxProps, StylesApiProps<ButtonFactory> {
"data-disabled"?: boolean
/** Controls button `height`, `font-size` and horizontal `padding` @default `'sm'` */
size?: MantineSize | `compact-${MantineSize}` | (string & {})
/** Key of `theme.colors` or any valid CSS color @default `theme.primaryColor` */
color?: MantineColor
/** Sets `justify-content` of `inner` element, can be used to change distribution of sections and label @default `'center'` */
justify?: React.CSSProperties["justifyContent"]
/** Content displayed on the left side of the button label */
leftSection?: React.ReactNode
/** Content displayed on the right side of the button label */
rightSection?: React.ReactNode
/** If set, the button takes 100% width of its parent container @default `false` */
fullWidth?: boolean
/** Key of `theme.radius` or any valid CSS value to set `border-radius` @default `theme.defaultRadius` */
radius?: MantineRadius
/** Gradient configuration used when `variant="gradient"` @default `theme.defaultGradient` */
gradient?: MantineGradient
/** Sets `disabled` attribute, applies disabled styles */
disabled?: boolean
/** Button content */
children?: React.ReactNode
/** If set, the `Loader` component is displayed over the button */
loading?: boolean
/** Props added to the `Loader` component (only visible when `loading` prop is set) */
loaderProps?: LoaderProps
/** If set, adjusts text color based on background color for `filled` variant */
autoContrast?: boolean
}
export type ButtonFactory = PolymorphicFactory<{
props: ButtonProps
defaultRef: HTMLButtonElement
defaultComponent: "button"
stylesNames: ButtonStylesNames
vars: ButtonCssVariables
variant: ButtonVariant
staticComponents: {
Group: typeof ButtonGroup
GroupSection: typeof ButtonGroupSection
}
}>
const loaderTransition: MantineTransition = {
in: { opacity: 1, transform: `translate(-50%, calc(-50% + ${rem(1)}))` },
out: { opacity: 0, transform: "translate(-50%, -200%)" },
common: { transformOrigin: "center" },
transitionProperty: "transform, opacity",
}
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,
},
}
}
)
export const Button = polymorphicFactory<ButtonFactory>((_props, ref) => {
const props = useProps("Button", null, _props)
const {
style,
vars,
className,
color,
disabled,
children,
leftSection,
rightSection,
fullWidth,
variant,
radius,
loading,
loaderProps,
gradient,
classNames,
styles,
unstyled,
"data-disabled": dataDisabled,
autoContrast,
mod,
attributes,
...others
} = props
const getStyles = useStyles<ButtonFactory>({
name: "Button",
props,
classes,
className,
style,
classNames,
styles,
unstyled,
attributes,
vars,
varsResolver,
})
const hasLeftSection = !!leftSection
const hasRightSection = !!rightSection
return (
<UnstyledButton
ref={ref}
{...getStyles("root", { active: !disabled && !loading && !dataDisabled })}
unstyled={unstyled}
variant={variant}
disabled={disabled || loading}
mod={[
{
disabled: disabled || dataDisabled,
loading,
block: fullWidth,
"with-left-section": hasLeftSection,
"with-right-section": hasRightSection,
},
mod,
]}
{...others}
>
{typeof loading === "boolean" && (
<Transition mounted={loading} transition={loaderTransition} duration={150}>
{(transitionStyles) => (
<Box
component="span"
{...getStyles("loader", { style: transitionStyles })}
aria-hidden
>
<Loader
color="var(--button-color)"
size="calc(var(--button-height) / 1.8)"
{...loaderProps}
/>
</Box>
)}
</Transition>
)}
<span {...getStyles("inner")}>
{leftSection && (
<Box component="span" {...getStyles("section")} mod={{ position: "left" }}>
{leftSection}
</Box>
)}
<Box component="span" mod={{ loading }} {...getStyles("label")}>
{children}
</Box>
{rightSection && (
<Box component="span" {...getStyles("section")} mod={{ position: "right" }}>
{rightSection}
</Box>
)}
</span>
</UnstyledButton>
)
})
Button.classes = classes
Button.displayName = "@mantine/core/Button"
Button.Group = ButtonGroup
Button.GroupSection = ButtonGroupSection
style shipped on npm
the style is mostly the same. It is probably processed by PostCSS. It does not use CSS nesting.
It is then processed even more and merged to a gigantic 232kb stylesheet, shipped on npm too, the one we import from our app.