9.1.0
shadcn-ui/ui9.1.0Apr 21, 2026by rtivital
AI Summary
This release adds performance optimizations like React 19 style deduplication, new input masking hooks, and a new Treemap chart component.
Key Highlights
- New `deduplicateInlineStyles` prop for React 19 style tag deduplication
- New `use-mask` hook for real-time input formatting
- New `Treemap` chart component for hierarchical data
- New `use-roving-index` hook for keyboard navigation
New Features
- `deduplicateInlineStyles` prop
- `use-mask` hook
- `MaskInput` component
- `Treemap` component
- `use-roving-index` hook
- TimePicker duration type
- Heatmap legend
- MonthPicker and YearPicker presets
Full Release Notes
[View changelog with demos on mantine.dev website](https://mantine.dev/changelog/9-1-0)
## Support Mantine development
You can now sponsor Mantine development with [OpenCollective](https://opencollective.com/mantinedev).
All funds are used to improve Mantine and create new features and components.
## deduplicateInlineStyles
New `deduplicateInlineStyles` prop on [MantineProvider](https://mantine.dev/theming/mantine-provider) enables
React 19 style tag deduplication for responsive [style props](https://mantine.dev/styles/style-props).
When many components share the same responsive style prop values, only a single `<style />`
tag is generated and hoisted to `<head />` instead of each component injecting its own:
```tsx
import { MantineProvider } from '@mantine/core';
function Demo() {
return (
<MantineProvider deduplicateInlineStyles>
{/* Your app here */}
</MantineProvider>
);
}
```
This can significantly improve performance when rendering large lists of components
with identical responsive style props. See the
[styles performance guide](https://mantine.dev/styles/styles-performance) for more details.
## use-mask hook
New [use-mask](https://mantine.dev/hooks/use-mask) hook attaches real-time input masking to any `<input>` element via
a ref callback. It formats user input against a defined pattern and exposes both the masked display
value and the raw unmasked value. The hook supports built-in and custom tokens, dynamic masks,
character transforms, optional segments, and regex array format:
```tsx
import { TextInput, Text } from '@mantine/core';
import { useMask } from '@mantine/hooks';
function Demo() {
const { ref, value, rawValue } = useMask({ mask: '(999) 999-9999' });
return (
<>
<TextInput ref={ref} label="Phone number" placeholder="(___) ___-____" />
<Text size="sm" mt="sm">Masked value: {value}</Text>
<Text size="sm">Raw value: {rawValue}</Text>
</>
);
}
```
## MaskInput component
New [MaskInput](https://mantine.dev/core/mask-input) component is a wrapper around [use-mask](https://mantine.dev/hooks/use-mask) hook
that provides all standard input props (label, description, error, etc.) and supports all mask options:
```tsx
import { MaskInput } from '@mantine/core';
function Demo() {
return (
<MaskInput
variant="default" size="sm" radius="md" label="Input label" withAsterisk={false} description="Input description" error=""
mask="(999) 999-9999"
placeholder="(___) ___-____"
/>
);
}
```
## Treemap component
New [Treemap](https://mantine.dev/charts/treemap) component displays hierarchical data as a set of nested
rectangles. It is based on the [Treemap recharts component](https://recharts.org/en-US/api/Treemap):
```tsx
// Demo.tsx
import { Treemap } from '@mantine/charts';
import { data } from './data';
function Demo() {
return <Treemap data={data} />;
}
// data.ts
export const data = [
{
name: 'Frontend',
color: 'blue.8',
children: [
{ name: 'React', value: 400 },
{ name: 'Vue', value: 200 },
{ name: 'Angular', value: 150 },
],
},
{
name: 'Backend',
color: 'teal.8',
children: [
{ name: 'Node', value: 300 },
{ name: 'Python', value: 250 },
{ name: 'Go', value: 100 },
],
},
{
name: 'Mobile',
color: 'red.8',
children: [
{ name: 'React Native', value: 200 },
{ name: 'Flutter', value: 180 },
],
},
];
```
## TimePicker duration type
[TimePicker](https://mantine.dev/dates/time-picker) component now supports `type="duration"` prop that allows
entering durations that exceed 24 hours. In this mode, the hours field has no upper limit
and the input width adjusts dynamically based on the entered value:
```tsx
import { TimePicker } from '@mantine/dates';
function Demo() {
return <TimePicker label="Enter duration" type="duration" withSeconds />;
}
```
## Heatmap legend
[Heatmap](https://mantine.dev/charts/heatmap) component now supports `withLegend` prop that displays
a color legend below the chart. Use `legendLabels` prop to customize labels
(default: `['Less', 'More']`):
```tsx
// Demo.tsx
import { Heatmap } from '@mantine/charts';
import { data } from './data';
function Demo() {
return (
<Heatmap
data={data}
startDate="2024-02-16"
endDate="2025-02-16"
withMonthLabels
withWeekdayLabels
withLegend
/>
);
}
// data.ts
export const data = ${JSON.stringify(data, null, 2)};
```
## MonthPicker and YearPicker presets
[MonthPicker](https://mantine.dev/dates/month-picker) and [YearPicker](https://mantine.dev/dates/year-picker) components now support
`presets` prop that allows adding predefined values to pick from. Presets are also available
in [MonthPickerInput](https://mantine.dev/dates/month-picker-input) and [YearPickerInput](https://mantine.dev/dates/year-picker-input)
components:
```tsx
import dayjs from 'dayjs';
import { MonthPicker } from '@mantine/dates';
function Demo() {
return (
<MonthPicker
presets={[
{ value: dayjs().startOf('month').format('YYYY-MM-DD'), label: 'This month' },
{ value: dayjs().add(1, 'month').startOf('month').format('YYYY-MM-DD'), label: 'Next month' },
{ value: dayjs().subtract(1, 'month').startOf('month').format('YYYY-MM-DD'), label: 'Last month' },
{ value: dayjs().add(6, 'month').startOf('month').format('YYYY-MM-DD'), label: 'In 6 months' },
{ value: dayjs().add(1, 'year').startOf('month').format('YYYY-MM-DD'), label: 'Next year' },
{ value: dayjs().subtract(1, 'year').startOf('month').format('YYYY-MM-DD'), label: 'Last year' },
]}
/>
);
}
```
## use-roving-index hook
New [use-roving-index](https://mantine.dev/hooks/use-roving-index) hook implements the
[roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex)
keyboard navigation pattern. It manages `tabIndex` state for a group of focusable elements,
handles arrow key navigation with disabled item skipping, and supports both 1D lists and 2D grids:
```tsx
import { Button, Group } from '@mantine/core';
import { useRovingIndex } from '@mantine/hooks';
const items = ['Bold', 'Italic', 'Underline', 'Strikethrough', 'Code'];
function Demo() {
const { getItemProps } = useRovingIndex({
total: items.length,
orientation: 'horizontal',
loop: true,
});
return (
<Group gap="xs">
{items.map((item, index) => (
<Button key={item} variant="default" {...getItemProps({ index })}>
{item}
</Button>
))}
</Group>
);
}
```
## Tree drag and drop
[Tree](https://mantine.dev/core/tree) component now supports drag-and-drop reordering of nodes.
Provide `onDragDrop` callback to enable it, and use the `moveTreeNode` utility
to update data based on the result:
```tsx
import { useState } from 'react';
import { CaretDownIcon } from '@phosphor-icons/react';
import { Group, moveTreeNode, RenderTreeNodePayload, Tree, TreeNodeData } from '@mantine/core';
const data: TreeNodeData[] = [
{
label: 'Pages',
value: 'pages',
children: [
{ label: 'index.tsx', value: 'pages/index.tsx' },
{ label: 'about.tsx', value: 'pages/about.tsx' },
{ label: 'contact.tsx', value: 'pages/contact.tsx' },
],
},
{
label: 'Components',
value: 'components',
children: [
{ label: 'Header.tsx', value: 'components/Header.tsx' },
{ label: 'Footer.tsx', value: 'components/Footer.tsx' },
{ label: 'Sidebar.tsx', value: 'components/Sidebar.tsx' },
],
},
{ label: 'package.json', value: 'package.json' },
{ label: 'tsconfig.json', value: 'tsconfig.json' },
];
function Leaf({ node, expanded, hasChildren, elementProps }: RenderTreeNodePayload) {
return (
<Group gap={5} {...elementProps}>
{hasChildren && (
<CaretDownIcon
size={18}
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
)}
<span>{node.label}</span>
</Group>
);
}
function Demo() {
const [treeData, setTreeData] = useState(data);
return (
<Tree
data={treeData}
onDragDrop={(payload) =>
setTreeData((current) => moveTreeNode(current, payload))
}
renderNode={(payload) => <Leaf {...payload} />}
/>
);
}
```
## Tree async loading
[Tree](https://mantine.dev/core/tree) now supports lazy loading of children. Set `hasChildren: true`
on a node without providing `children` – when the node is expanded for the first time,
`onLoadChildren` callback passed to `useTree` is called. Use `mergeAsyncChildren`
utility to splice loaded children into your data:
```tsx
import { useState } from 'react';
import { CaretDownIcon, SpinnerIcon } from '@phosphor-icons/react';
import {
Group,
mergeAsyncChildren,
RenderTreeNodePayload,
Tree,
TreeNodeData,
useTree,
} from '@mantine/core';
const initialData: TreeNodeData[] = [
{ label: 'Documents', value: 'documents', hasChildren: true },
{ label: 'Photos', value: 'photos', hasChildren: true },
{ label: 'README.md', value: 'readme' },
];
// Simulates an API call to load children
async function fetchChildren(parentValue: string): Promise<TreeNodeData[]> {
await new Promise((resolve) => setTimeout(resolve, 1000));
return [
{ label: `${parentValue}/file-1.txt`, value: `${parentValue}/file-1.txt` },
{ label: `${parentValue}/file-2.txt`, value: `${parentValue}/file-2.txt` },
{
label: `${parentValue}/subfolder`,
value: `${parentValue}/subfolder`,
hasChildren: true,
},
];
}
function Leaf({ node, expanded, hasChildren, elementProps, isLoading }: RenderTreeNodePayload) {
return (
<Group gap={5} wrap="nowrap" {...elementProps}>
{isLoading ? (
<SpinnerIcon size={18} style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }} />
) : (
hasChildren && (
<CaretDownIcon
size={18}
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', flexShrink: 0 }}
/>
)
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.label}
</span>
</Group>
);
}
function Demo() {
const [data, setData] = useState(initialData);
const tree = useTree({
onLoadChildren: async (value) => {
const children = await fetchChildren(value);
setData((prev) => mergeAsyncChildren(prev, value, children));
},
});
return (
<Tree
data={data}
tree={tree}
renderNode={(payload) => <Leaf {...payload} />}
/>
);
}
```
## Tree search and filtering
[Tree](https://mantine.dev/core/tree) now includes `filterTreeData` utility to filter tree data based on
a search query. Matching nodes and their ancestors are preserved in the result. You can
provide a custom filter function for advanced matching (for example, fuzzy search with fuse.js):
```tsx
import { useMemo, useState } from 'react';
import {
filterTreeData,
getTreeExpandedState,
TextInput,
Tree,
TreeNodeData,
useTree,
} from '@mantine/core';
const data: TreeNodeData[] = [
{
label: 'src',
value: 'src',
children: [
{
label: 'components',
value: 'src/components',
children: [
{ label: 'Accordion.tsx', value: 'src/components/Accordion.tsx' },
{ label: 'Tree.tsx', value: 'src/components/Tree.tsx' },
{ label: 'Button.tsx', value: 'src/components/Button.tsx' },
{ label: 'Input.tsx', value: 'src/components/Input.tsx' },
],
},
{
label: 'hooks',
value: 'src/hooks',
children: [
{ label: 'use-debounce.ts', value: 'src/hooks/use-debounce.ts' },
{ label: 'use-media-query.ts', value: 'src/hooks/use-media-query.ts' },
],
},
],
},
{
label: 'public',
value: 'public',
children: [
{ label: 'favicon.ico', value: 'public/favicon.ico' },
{ label: 'logo.svg', value: 'public/logo.svg' },
],
},
{ label: 'package.json', value: 'package.json' },
{ label: 'tsconfig.json', value: 'tsconfig.json' },
];
function Demo() {
const [search, setSearch] = useState('');
const tree = useTree();
const filteredData = useMemo(
() => filterTreeData(data, search),
[search]
);
const handleSearchChange = (value: string) => {
setSearch(value);
if (value.trim()) {
const next = filterTreeData(data, value);
tree.setExpandedState(getTreeExpandedState(next, '*'));
} else {
tree.collapseAllNodes();
}
};
return (
<div>
<TextInput
placeholder="Search..."
mb="sm"
value={search}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
/>
<Tree data={filteredData} tree={tree} />
</div>
);
}
```
## Tree connecting lines
[Tree](https://mantine.dev/core/tree) now supports `withLines` prop to display connecting lines
showing parent-child relationships. Lines adapt to `levelOffset` spacing automatically:
```tsx
import { getTreeExpandedState, Tree, useTree } from '@mantine/core';
import { data } from './data';
function Demo() {
const tree = useTree({
initialExpandedState: getTreeExpandedState(data, '*'),
});
return <Tree data={data} tree={tree} withLines />;
}
```
## Tree virtualization
[Tree](https://mantine.dev/core/tree) now provides `flattenTreeData` utility and `FlatTreeNode` component
for virtualized rendering of large trees. The component does not depend on any
virtualization library – you supply one yourself (e.g., `@tanstack/react-virtual`):
```tsx
import { useMemo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
FlatTreeNode,
flattenTreeData,
getTreeExpandedState,
TreeNodeData,
useTree,
} from '@mantine/core';
const ITEM_HEIGHT = 30;
function generateTreeData(count: number): TreeNodeData[] {
const result: TreeNodeData[] = [];
let id = 0;
function addChildren(
parent: TreeNodeData[],
depth: number,
remaining: { count: number }
) {
const childCount = depth === 0 ? 20 : Math.floor(Math.random() * 8) + 2;
for (let i = 0; i < childCount && remaining.count > 0; i++) {
id++;
remaining.count--;
const hasChild =
depth < 3 && remaining.count > 0 && Math.random() > 0.3;
const node: TreeNodeData = {
label: `${hasChild ? 'Folder' : 'File'} ${id}`,
value: `node-${id}`,
children: hasChild ? [] : undefined,
};
if (hasChild) {
addChildren(node.children!, depth + 1, remaining);
}
parent.push(node);
}
}
addChildren(result, 0, { count });
return result;
}
const largeData = generateTreeData(2000);
const initialExpandedState = getTreeExpandedState(largeData, '*');
function Demo() {
const tree = useTree({
initialExpandedState,
});
const flatList = useMemo(
() => flattenTreeData(largeData, tree.expandedState),
[tree.expandedState]
);
const scrollParentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: flatList.length,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: 20,
});
return (
<div ref={scrollParentRef} style={{ height: 400, overflow: 'auto' }}>
<div
data-tree-root
role="tree"
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<FlatTreeNode
key={flatList[virtualItem.index].node.value}
{...flatList[virtualItem.index]}
tree={tree}
expandOnClick
selectOnClick
tabIndex={virtualItem.index === 0 ? 0 : -1}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
/>
))}
</div>
</div>
);
}
```
## Tree checkStrictly mode
[useTree](https://mantine.dev/core/tree) hook now supports `checkStrictly` option. When enabled, checking
a parent node does not affect children and vice versa – each node's checked state is
fully independent:
```tsx
import { CaretDownIcon } from '@phosphor-icons/react';
import { Checkbox, Group, RenderTreeNodePayload, Tree, useTree } from '@mantine/core';
import { data } from './data';
const renderTreeNode = ({
node,
expanded,
hasChildren,
elementProps,
tree,
}: RenderTreeNodePayload) => {
const checked = tree.isNodeChecked(node.value);
return (
<Group gap="xs" {...elementProps}>
<Checkbox.Indicator
checked={checked}
onClick={() =>
checked
? tree.uncheckNode(node.value)
: tree.checkNode(node.value)
}
/>
<Group gap={5} onClick={() => tree.toggleExpanded(node.value)}>
<span>{node.label}</span>
{hasChildren && (
<CaretDownIcon
size={14}
style={{
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
)}
</Group>
</Group>
);
};
function Demo() {
const tree = useTree({ checkStrictly: true });
return (
<Tree
data={data}
tree={tree}
levelOffset={23}
expandOnClick={false}
renderNode={renderTreeNode}
/>
);
}
```
## Slider startPointValue
[Slider](https://mantine.dev/core/slider) component now supports `startPointValue` prop that changes
the origin of the filled bar. When set, the bar extends from the given value toward the
current value – to the left for values below the start point and to the right for values above it:
```tsx
import { Slider } from '@mantine/core';
function Demo() {
return (
<Slider
startPointValue={0}
min={-100}
max={100}
defaultValue={40}
marks={[
{ value: -100, label: '-100' },
{ value: -50, label: '-50' },
{ value: 0, label: '0' },
{ value: 50, label: '50' },
{ value: 100, label: '100' },
]}
/>
);
}
```
## WeekView forceCurrentTimeIndicator
[WeekView](https://mantine.dev/schedule/week-view) component now supports `forceCurrentTimeIndicator` prop.
When set, the current time indicator is displayed on the same day of week even when viewing
a different week:
```tsx
import { WeekView } from '@mantine/schedule';
import { events } from './data';
function Demo() {
return (
<WeekView
date="2030-06-10"
events={events}
withCurrentTimeIndicator
forceCurrentTimeIndicator
/>
);
}
```
## New demo: MonthView events rendering
New [MonthView](https://mantine.dev/schedule/month-view) demo shows how to use `renderEvent` to visually
differentiate all-day and timed events. All-day events render as regular colored bars,
while timed events display as a colored dot with the start time and title:
```tsx
// Demo.tsx
import dayjs from 'dayjs';
import { Box, UnstyledButton } from '@mantine/core';
import { MonthView, ScheduleEventData } from '@mantine/schedule';
function isAllDayEvent(event: ScheduleEventData) {
const start = dayjs(event.start);
const end = dayjs(event.end);
return start.isSame(start.startOf('day')) && end.isSame(end.startOf('day'));
}
const events: ScheduleEventData[] = [/* ...events */];
function Demo() {
return (
<MonthView
date={new Date()}
events={events}
renderEvent={(event, props) => {
if (isAllDayEvent(event)) {
return <UnstyledButton {...props} />;
}
const { children, className, style, ...others } = props;
return (
<UnstyledButton
{...others}
style={{
...style,
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 10,
whiteSpace: 'nowrap',
overflow: 'hidden',
pointerEvents: 'all',
cursor: 'pointer',
paddingInline: 2,
}}
>
<Box
component="span"
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: `var(--event-bg)`,
flexShrink: 0,
}}
/>
<span style={{ width: 28, flexShrink: 0 }}>{dayjs(event.start).format('h:mm')}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{event.title}
</span>
</UnstyledButton>
);
}}
/>
);
}
```
## Other changes
- [Tabs](https://mantine.dev/core/tabs) component now supports `keepMountedMode` prop that controls how inactive tab panels are hidden when `keepMounted` is `true`. Set `keepMountedMode="display-none"` to use `display: none` styles instead of the default `Activity` component.
- [useClickOutside](https://mantine.dev/hooks/use-click-outside) hook now supports `enabled` parameter to dynamically enable/disable the listener. The hook also uses `event.composedPath()` in both `ref` and `nodes` branches for consistent Shadow DOM support and correctly ignores clicks on detached DOM nodes in the single-ref mode.
- [useCounter](https://mantine.dev/hooks/use-counter) hook now supports `step` option to configure increment/decrement step size (default `1`).
- [useDebouncedCallback](https://mantine.dev/hooks/use-debounced-callback) hook now supports `maxWait` option to guarantee execution within a maximum time window during continuous calls, and `isPending()` method to check if a debounced call is waiting.
- [useDebouncedValue](https://mantine.dev/hooks/use-debounced-value) hook now returns a `flush` method to immediately apply the pending debounced value.
- [useScrollIntoView](https://mantine.dev/hooks/use-scroll-into-view) hook now supports `onScrollCancel` callback that fires when the scroll animation is interrupted by the user, and returns a `scrolling` boolean to indicate whether a scroll animation is in progress.