Skip to main content
Nexus Design System
GitHub

nxds with MUI

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

What nxds adds for you

nxds gives an MUI 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 command paletteCommandDialog
An app sidebarSidebar and SidebarProvider
A one-time code inputInputOTP
Resizable panesResizablePanelGroup and ResizablePanel
A filter barFilterBuilder
A date picker without MUI XDatePicker
A chart without MUI XChartContainer
A menu bar or right-click menuMenubar and ContextMenu
Keyboard shortcut hintsKbd
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. CssBaseline sets box-sizing, so delete the Preflight lines in app/nxds.css, as Add nxds to an existing app explains.

MUI needs no cascade layer. Its styles target its own class names, and CssBaseline only styles html and body.

Wrap the app in both providers. The nxds provider goes outside, so MUI can read the nxds mode:

// app/layout.tsx
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';

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 { MuiProvider } from './mui-provider';

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

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NxdsAppearanceScript
          storageKey={APPEARANCE_STORAGE_KEY}
          defaultState={defaultAppearance}
        />
      </head>
      <body>
        <NxdsAppearanceProvider
          storageKey={APPEARANCE_STORAGE_KEY}
          defaultState={defaultAppearance}
        >
          <AppRouterCacheProvider>
            <MuiProvider>{children}</MuiProvider>
          </AppRouterCacheProvider>
        </NxdsAppearanceProvider>
      </body>
    </html>
  );
}

MuiProvider holds MUI's ThemeProvider, as the next section shows. Render the nxds button on an existing screen, beside MUI's:

// app/page.tsx
import MuiButton from '@mui/material/Button';
import Stack from '@mui/material/Stack';

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

export default function Page() {
  return (
    <Stack direction="row" spacing={2}>
      <MuiButton variant="contained">Save</MuiButton>
      <Button variant="outline">Preview</Button>
    </Stack>
  );
}

MUI's overlays sit at z-index 1300 to 1500. Raise the nxds layers above them, so an nxds popup opens over an MUI dialog:

/* app/nxds.css, after the imports */
:root {
  --nx-z-index-modal: 1600;
  --nx-z-index-popover: 1700;
  --nx-z-index-toast: 1800;
}

Inside an nxds dialog, render MUI popups in place with disablePortal. It works on Menu, Popover and Autocomplete. On Select, pass MenuProps={{ disablePortal: true }}.

The mui-app example shows every step in a Vite app.

Share light and dark mode

nxds owns the mode. MUI reads the .dark class that nxds writes, and a small component passes each change to MUI:

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

import CssBaseline from '@mui/material/CssBaseline';
import {
  type StorageManager,
  ThemeProvider,
  useColorScheme,
} from '@mui/material/styles';
import { 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';

const nxdsModeStorage: StorageManager = ({ key }) => ({
  get: (fallback) =>
    key === 'mui-mode'
      ? readStoredNxdsAppearance(APPEARANCE_STORAGE_KEY, defaultAppearance).mode
      : fallback,
  set() {},
  subscribe: () => () => {},
});

function MuiFollowsNxds() {
  const { state, mounted } = useNxdsAppearance();
  const { setMode } = useColorScheme();

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

  return null;
}

export function MuiProvider({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider theme={theme} storageManager={nxdsModeStorage}>
      <CssBaseline />
      <MuiFollowsNxds />
      {children}
    </ThemeProvider>
  );
}
// app/theme.ts
'use client';

import { createTheme } from '@mui/material/styles';

import { brand } from './brand';

const primary = { main: brand.color };

export const theme = createTheme({
  colorSchemes: {
    light: { palette: { primary } },
    dark: { palette: { primary } },
  },
  cssVariables: { colorSchemeSelector: 'class' },
  typography: { fontFamily: brand.font },
});

colorSchemeSelector: 'class' puts MUI's dark colors under .dark. The nxds head script sets that class before the first paint, so MUI doesn't flash light. Leave out InitColorSchemeScript, because the nxds script already sets the class.

nxdsModeStorage starts MUI from the mode nxds saved, and saves nothing of its own. MUI then can't fall out of step on a reload or from another tab. MuiFollowsNxds passes each later change to MUI. Both use 'light', 'dark' and 'system'.

Switch the mode through nxds only:

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

import Button from '@mui/material/Button';

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

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

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

Don't call MUI's setMode yourself. MuiFollowsNxds does it.

Match your theme

Keep the values both themes read in one file. theme.ts above reads it too:

// app/brand.ts
export const brand = {
  color: '#1976d2',
  font: '"Roboto", "Helvetica", "Arial", sans-serif',
};
// app/appearance-state.ts
import { DEFAULT_NXDS_APPEARANCE, type NxdsAppearanceState } from '@nxds/core';

import { brand } from './brand';

export const APPEARANCE_STORAGE_KEY = 'app-appearance';

export const defaultAppearance = {
  ...DEFAULT_NXDS_APPEARANCE,
  brandColor: brand.color,
  surfaceTone: 'neutral',
  corners: 'subtle',
  prefs: { ...DEFAULT_NXDS_APPEARANCE.prefs, uiFont: brand.font },
} satisfies NxdsAppearanceState;

The server layout imports appearance-state.ts, so keep it free of the MUI theme. MUI's Next.js guide marks theme.ts 'use client'.

MUI themeAppearance fieldHow to match
palette.primary.mainbrandColorPass it as is. nxds builds the hover and pressed shades
palette.grey, palette.backgroundsurfaceToneneutral matches MUI's pure grey
shape.borderRadiuscorners0 is square, 4 is subtle, 8 is smooth, 12 is round, 16 is extra-round
spacingdensityCompare in the Playground. compact and tight suit dense MUI themes
shadowselevationCompare in the Playground. flat turns shadows off
NonestrokeMUI has no border width setting, so keep normal
typography.fontFamilyprefs.uiFontPass it as is
typography.fontSizeprefs.uiFontSizeBoth default to 14
palette.modemodenxds owns it, as Share light and dark mode shows
palette.contrastThresholdlightContrast, darkContrastNo direct match. nxds checks every text pair against a contrast floor, so keep 50 to start

Where nxds fits

Each row maps an MUI component to the nxds parts that replace it, and the props that change. nxds components follow Radix naming: onValueChange, onCheckedChange and onOpenChange take the new value, not an event.

MUInxdsWhat changes
ButtonButtonvariant contained, outlined, text → default, outline, ghost
Button color="error"Button variant="destructive"
IconButtonButton size="icon"Give it an aria-label
ButtonGroupButtonGroup
TextFieldField, FieldLabel, Input, FieldDescription, FieldErrorlabel, helperText and error become parts
TextField multilineTextarea
InputAdornmentInputGroup, InputGroupAddon, InputGroupInput
FormControl, FormLabel, FormHelperTextField, FieldLabel, FieldDescription
FormControlLabelField orientation="horizontal" with FieldLabel
FormGroup, FieldSetFieldGroup, FieldSet, FieldLegend
CheckboxCheckboxonChange(event) → onCheckedChange(checked)
SwitchSwitchonChange(event) → onCheckedChange(checked)
RadioGroup, RadioRadioGroup, RadioGroupItemonChange(event) → onValueChange(value)
SelectSelect, SelectTrigger, SelectValue, SelectContent, SelectItemonChange(event) → onValueChange(value)
NativeSelectNativeSelect, NativeSelectOption
Select multipleMultiSelectvalue → values, onChange → onValuesChange(values)
AutocompleteComboboxCompose a ComboboxTrigger, ComboboxInput and ComboboxItem list
Autocomplete multipleMultiSelect
SliderSlidervalue is an array. onChange → onValueChange(value)
ToggleButtonToggleselected → pressed, onChange → onPressedChange
ToggleButtonGroupToggleGroup, ToggleGroupItemSet type="single" or type="multiple". onChange → onValueChange
DialogDialog, DialogContent, DialogHeader, DialogTitle, DialogBodyonClose → onOpenChange(open)
DialogActionsDialogFooter
Dialog asking to confirmAlertDialog
ModalDialog
Drawer, temporarySheet, SheetContent side="left"anchor → side. onClose → onOpenChange(open)
Drawer, permanentSidebar
SwipeableDrawer, bottomDrawer
Menu, MenuItemDropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItemNo anchorEl: the trigger opens the menu
Popover, PopperPopover, PopoverTrigger, PopoverContentNo anchorEl: the trigger, or PopoverAnchor, places it
TooltipTooltip, TooltipTrigger, TooltipContenttitle → TooltipContent children
SnackbarToaster and toast()Render <Toaster /> once, then call toast('Saved')
AlertAlert, AlertTitle, AlertDescriptionseverity error, warning, info, success → variant destructive, warning, information, success
AccordionAccordion, AccordionItem, AccordionTrigger, AccordionContentOne Accordion holds every item. expanded → value
CollapseCollapsible, CollapsibleTrigger, CollapsibleContent
Tabs, TabTabs, TabsList, TabsTrigger, TabsContentonChange(event, value) → onValueChange(value)
BreadcrumbsBreadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink
Pagination, TablePaginationPagination, PaginationContent, PaginationLink
Card, CardHeader, CardContent, CardActionsCard, CardHeader, CardContent, CardFooter
PaperCard
List, ListItem, ListItemText, ListItemIconItemGroup, Item, ItemTitle, ItemDescription, ItemMedia
ListItemButtonItem asChild around a button or link
Table, TableHead, TableBody, TableRow, TableCellTable, TableHeader, TableBody, TableRow, TableCell, TableHeadHeader cells are TableHead
Avatar, AvatarGroupAvatar, AvatarImage, AvatarFallback, AvatarGroup
ChipBadge, or FilterChip for a removable filtercolor → variant. variant="outlined" → fill="outline"
DividerSeparator
CircularProgress, indeterminateSpinner
LinearProgressProgress
SkeletonSkeletonSize it with nx: classes
Typographynx:typography-* classesh1–h6 → nx:typography-heading-*, body1 → nx:typography-body-default, body2 → nx:typography-body-small
Box, Stack, Grid, Containernx: flex and grid classesAt the default density, Stack spacing={2} → nx:flex nx:flex-col nx:gap-4
useMediaQueryShow and Hide<Hide below="md"> hides content below the md breakpoint
MUI X DatePickerDatePickerTakes React Day Picker props: mode, selected, onSelect
MUI X ChartsChartContainerBuilt on Recharts

Swap a component (optional)

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

// Before
import TextField from '@mui/material/TextField';

<TextField
  label="Email"
  type="email"
  helperText="We never share it."
  error={Boolean(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.

What stays on MUI

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

  • Stepper
  • Rating
  • SpeedDial and Fab
  • Badge, the count on an icon
  • Backdrop
  • BottomNavigation
  • ImageList and Masonry
  • Timeline
  • MUI X TreeView and DataGrid
  • CircularProgress with a value