Skip to main content
Nexus Design System
GitHub

nxds with Mantine

Add nxds components to a Mantine app, and keep Mantine for everything else. These steps use a Next.js App Router app on Mantine 9.

What nxds adds for you

nxds gives a Mantine app parts it doesn't have, themed from one appearance object. Introduction covers the theme engine, the copy-and-own source and the agent support.

You neednxds part
A collapsible app sidebarSidebar and SidebarProvider
A filter barFilterBuilder
A menu bar or right-click menuMenubar and ContextMenu
Resizable panesResizablePanelGroup and ResizablePanel
An input with buttons insideInputGroup
An empty screenEmptyState
Label and value pairsDescriptionList
A chat threadMessage and Bubble

Add your first nxds component

Set up nxds, then add a component:

npx nxds-cli@latest init
npx nxds-cli@latest add button

init adds the provider and the theme script to app/layout.tsx, and creates app/nxds.css. Mantine's styles set box-sizing, so delete the Preflight lines in app/nxds.css, as Add nxds to an existing app explains.

@mantine/core/styles.css sits outside any cascade layer, so its button { font: inherit } would beat the nxds button's type. Import styles.layer.css instead, which puts the same CSS in a mantine layer. Name that layer first, so it sits below the nxds utilities:

/* app/globals.css */
@layer mantine, properties, theme, base, components, utilities, nx-runtime;
@import '@mantine/core/styles.layer.css';

Import globals.css in app/layout.tsx instead of @mantine/core/styles.css.

Render the nxds button on an existing screen, beside Mantine's:

// app/page.tsx
import { Button as MantineButton, Group } from '@mantine/core';

import { Button } from '@/components/nxds/components/button/button';

export default function Page() {
  return (
    <Group gap="sm">
      <MantineButton>Save</MantineButton>
      <Button variant="outline">Preview</Button>
    </Group>
  );
}

Mantine's overlays sit at z-index 200 to 400. Raise the nxds layers above them, so an nxds popup opens over a Mantine modal:

/* app/nxds.css, after the imports */
:root {
  --nx-z-index-modal: 500;
  --nx-z-index-popover: 600;
  --nx-z-index-toast: 700;
}

Inside an nxds dialog, render Mantine popups in place with withinPortal={false}. On Select, MultiSelect and Autocomplete, pass comboboxProps={{ withinPortal: false }}.

Share light and dark mode

nxds owns the mode. Mantine sets its own data-mantine-color-scheme attribute on <html>, so a small component passes each nxds change to Mantine:

// app/mantine-provider.tsx
'use client';

import {
  type MantineColorSchemeManager,
  MantineProvider,
  useMantineColorScheme,
} from '@mantine/core';
import { type NxdsAppearanceMode, readStoredNxdsAppearance } from '@nxds/core';
import { useEffect } from 'react';

import { useNxdsAppearance } from '@/components/nxds/components/appearance/provider';

import { APPEARANCE_STORAGE_KEY, defaultAppearance } from './appearance-state';
import { theme } from './theme';

function toMantineColorScheme(mode: NxdsAppearanceMode) {
  return mode === 'system' ? 'auto' : mode;
}

const nxdsColorSchemeManager: MantineColorSchemeManager = {
  get: () =>
    toMantineColorScheme(
      readStoredNxdsAppearance(APPEARANCE_STORAGE_KEY, defaultAppearance).mode
    ),
  set() {},
  subscribe() {},
  unsubscribe() {},
  clear() {},
};

function MantineFollowsNxds() {
  const { state, mounted } = useNxdsAppearance();
  const { setColorScheme } = useMantineColorScheme();

  useEffect(() => {
    if (mounted) setColorScheme(toMantineColorScheme(state.mode));
  }, [mounted, state.mode, setColorScheme]);

  return null;
}

export function AppMantineProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <MantineProvider theme={theme} colorSchemeManager={nxdsColorSchemeManager}>
      <MantineFollowsNxds />
      {children}
    </MantineProvider>
  );
}

theme is the theme your app already passes to MantineProvider. Mantine calls the system mode 'auto', so toMantineColorScheme maps nxds 'system' to it. nxdsColorSchemeManager starts Mantine from the mode nxds saved, and saves nothing of its own. Mantine then can't fall out of step on a reload or from another tab.

Leave out Mantine's ColorSchemeScript, because it reads only Mantine's saved mode. One line in <head> sets Mantine's attribute from the nxds .dark class before the first paint. Put the Mantine provider inside the nxds provider:

// app/layout.tsx
import { mantineHtmlProps } from '@mantine/core';

import { NxdsAppearanceProvider } from '@/components/nxds/components/appearance/provider';
import { NxdsAppearanceScript } from '@/components/nxds/components/appearance/provider/server';

import { APPEARANCE_STORAGE_KEY, defaultAppearance } from './appearance-state';
import { AppMantineProvider } from './mantine-provider';

import './globals.css';
import './nxds.css';

const MANTINE_THEME_SCRIPT =
  "document.documentElement.dataset.mantineColorScheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" {...mantineHtmlProps}>
      <head>
        <NxdsAppearanceScript
          storageKey={APPEARANCE_STORAGE_KEY}
          defaultState={defaultAppearance}
        />
        <script dangerouslySetInnerHTML={{ __html: MANTINE_THEME_SCRIPT }} />
      </head>
      <body>
        <NxdsAppearanceProvider
          storageKey={APPEARANCE_STORAGE_KEY}
          defaultState={defaultAppearance}
        >
          <AppMantineProvider>{children}</AppMantineProvider>
        </NxdsAppearanceProvider>
      </body>
    </html>
  );
}

mantineHtmlProps includes suppressHydrationWarning, which nxds needs too. If you pass a nonce to NxdsAppearanceScript, pass the same one to the Mantine script.

Switch the mode through nxds only, not setColorScheme:

// app/mode-toggle.tsx
'use client';

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

import { useNxdsAppearance } from '@/components/nxds/components/appearance/provider';

export function ModeToggle() {
  const { setState, resolvedMode } = useNxdsAppearance();
  const next = resolvedMode === 'dark' ? 'light' : 'dark';

  return (
    <Button
      variant="default"
      onClick={() => setState((current) => ({ ...current, mode: next }))}
    >
      Switch to {next}
    </Button>
  );
}

Match your theme

// app/appearance-state.ts
import { DEFAULT_NXDS_APPEARANCE, type NxdsAppearanceState } from '@nxds/core';

export const APPEARANCE_STORAGE_KEY = 'app-appearance';

export const defaultAppearance = {
  ...DEFAULT_NXDS_APPEARANCE,
  brandColor: '#228be6',
  surfaceTone: 'gray',
  corners: 'smooth',
} satisfies NxdsAppearanceState;
Mantine themeAppearance fieldHow to match
primaryColor, primaryShadebrandColorUse the light shade, colors[primaryColor][6] by default. Mantine's blue is #228be6
colors.graysurfaceTonegray is closest to Mantine's gray
defaultRadiuscornersxs and sm are subtle, md is smooth, lg and xl are extra-round. Mantine's default is md
spacing, scaledensityCompare in the Playground
shadowselevationCompare in the Playground. flat turns shadows off
NonestrokeMantine has no border width setting, so keep normal
fontFamilyprefs.uiFontPass the same font stack
fontSizes.mdprefs.uiFontSizeMantine's default is 16 and nxds's is 14. Set 16 to match Mantine's text
The color schememodenxds owns it, as Share light and dark mode shows

Where nxds fits

Many Mantine inputs pass an event to onChange. nxds handlers take the value itself: onValueChange(value), onCheckedChange(checked), onOpenChange(open).

MantinenxdsWhat changes
ButtonButtonvariant filled, light, outline, subtle, default → default, secondary, outline, ghost, outline
Button color="red"Button variant="destructive"nxds has no color prop. The brand comes from the appearance
ActionIconButton size="icon"Give it an aria-label
Button.GroupButtonGroup
TextInputField, FieldLabel, Input, FieldDescription, FieldErrorlabel, description and error become parts
TextareaTextarea in a Field
Input.WrapperField
FieldsetFieldSet, FieldLegend
CheckboxCheckbox with a FieldLabelonChange(event) → onCheckedChange(checked)
SwitchSwitchonChange(event) → onCheckedChange(checked)
Radio.Group, RadioRadioGroup, RadioGroupItemonChange(value) → onValueChange(value)
Checkbox.Card, Radio.CardChoiceCard
SelectSelect, SelectTrigger, SelectValue, SelectContent, SelectItemItems are children, not data. onChange → onValueChange(value)
NativeSelectNativeSelect, NativeSelectOption
MultiSelectMultiSelectonChange(values) → onValuesChange(values)
Autocomplete, ComboboxComboboxCompose a ComboboxTrigger, ComboboxInput and ComboboxItem list
PinInputInputOTP, InputOTPGroup, InputOTPSlot
Slider, RangeSliderSlidervalue is an array, with two values for a range. onChange → onValueChange
SegmentedControlToggleGroup type="single"onChange → onValueChange
Chip, Chip.GroupToggle, ToggleGroupchecked → pressed
ModalDialog, DialogContent, DialogHeader, DialogTitle, DialogBodyopened → open, onClose → onOpenChange(open), title → DialogTitle
Modal asking to confirmAlertDialog
DrawerSheet, or Drawer for a bottom sheetposition → side on SheetContent
MenuDropdownMenuMenu.Target → DropdownMenuTrigger, Menu.Dropdown → DropdownMenuContent
Popover, HoverCardPopover, HoverCardPopover.Target → PopoverTrigger, Popover.Dropdown → PopoverContent
TooltipTooltip, TooltipTrigger, TooltipContentlabel → TooltipContent children
@mantine/notificationsToaster, toast()notifications.show({ message }) → toast(message)
AlertAlert, AlertTitle, AlertDescriptioncolor → variant: destructive, warning, information, success
AccordionAccordion, AccordionItem, AccordionTrigger, AccordionContentmultiple → type="multiple". Accordion.Control → AccordionTrigger, Accordion.Panel → AccordionContent
CollapseCollapsible, CollapsibleTrigger, CollapsibleContent
TabsTabs, TabsList, TabsTrigger, TabsContentTabs.Tab → TabsTrigger, Tabs.Panel → TabsContent. onChange → onValueChange
BreadcrumbsBreadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink
PaginationPagination, PaginationContent, PaginationLink
Card, PaperCard, CardHeader, CardContent, CardFooter
Avatar, Avatar.GroupAvatar, AvatarImage, AvatarFallback, AvatarGroup
BadgeBadgecolor → variant. variant="light" → fill="light", variant="outline" → fill="outline"
Pill with a remove buttonFilterChip
DividerSeparator
ProgressProgress
Loader, SkeletonSpinner, Skeleton
TableTable, TableHeader, TableBody, TableRow, TableHead, TableCellTable.Thead → TableHeader, Table.Th → TableHead, Table.Td → TableCell
Kbd, ScrollAreaKbd, ScrollArea
AppShell, NavLinkSidebarProvider, Sidebar, SidebarInset, SidebarMenuButton
Stack, Group, Flex, Grid, SimpleGrid, Containernx: flex and grid classesAt the default density, gap="md" → nx:gap-4
Text, Titlenx:typography-* classesSuch as nx:typography-heading-large and nx:typography-body-default
visibleFrom, hiddenFromShow and HidehiddenFrom="md" → <Hide above="md">
@mantine/dates DatePickerDatePickerTakes React Day Picker props: mode, selected, onSelect
@mantine/spotlightCommandDialog
@mantine/chartsChartContainerBuilt on Recharts, like Mantine's charts
@mantine/carouselCarouselBuilt on Embla, like Mantine's carousel

Swap a component (optional)

Swap a component when you touch the screen anyway. This form field moves from Mantine to nxds:

// Before
import { TextInput } from '@mantine/core';

<TextInput
  label="Email"
  type="email"
  description="We never share it."
  error={error}
/>;
// After
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@/components/nxds/components/field/field';
import { Input } from '@/components/nxds/components/input/input';

<Field data-invalid={Boolean(error)}>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" type="email" aria-invalid={Boolean(error)} />
  <FieldDescription>We never share it.</FieldDescription>
  <FieldError>{error}</FieldError>
</Field>;

To check the nx: classes in the files you move over, add the nxds lint rules. @mantine/form keeps working: pass its values and errors to nxds parts.

What stays on Mantine

nxds has no part for these. Keep them on Mantine:

  • Stepper
  • Rating
  • Timeline
  • Tree
  • ColorInput and ColorPicker
  • FileInput and @mantine/dropzone
  • NumberInput and PasswordInput
  • TagsInput
  • RingProgress and SemiCircleProgress
  • Spoiler, Indicator and LoadingOverlay
  • JsonInput and @mantine/tiptap