Skip to main content
Nexus Design System
GitHub

nxds with shadcn/ui

Add nxds components to a shadcn/ui app, and keep components/ui as it is. These steps use a Next.js App Router app with Tailwind 4.

What nxds adds for you

nxds started from shadcn/ui, so the parts read the same. What changes is the theme: one appearance object sets color, density, corners, shadows and border width, and every text pair is contrast-checked. Introduction compares the two.

nxds also has parts shadcn/ui doesn't ship:

You neednxds part
A filter barFilterBuilder, FilterChip
A select with several valuesMultiSelect
Large radio or checkbox choicesChoiceCard, ChoiceRow
A page title with actionsPageHeader
Label and value pairsDescriptionList
A chat threadMessage, Bubble, MessageScroller
A file in a message or formAttachment
Content shown only at some widthsShow, Hide

Add your first nxds component

Set up nxds, then add a component:

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

init creates app/nxds.css and imports it after globals.css. It also adds the provider and the theme script to app/layout.tsx.

Your app now runs two Tailwind builds. globals.css keeps its @import "tailwindcss" and its unprefixed classes. app/nxds.css builds the nx: classes:

/* app/nxds.css */
@import '../components/nxds/tailwind/nxds.css';
@import 'tw-animate-css';

Both builds use Tailwind's cascade layers, so nothing needs a layer of its own. Your components/ui files stay where they are, and nxds goes in components/nxds. Import each Button from its own folder, and rename one:

// app/page.tsx
import { Button as NxdsButton } from '@/components/nxds/components/button/button';
import { Button } from '@/components/ui/button';

export default function Page() {
  return (
    <div className="flex gap-2">
      <Button>Save</Button>
      <NxdsButton variant="outline">Preview</NxdsButton>
    </div>
  );
}

shadcn/ui overlays use z-50, the same as the nxds modal layer. nxds popovers and toasts sit higher, so they open above a shadcn/ui dialog. Inside an nxds dialog, use nxds popups: a shadcn/ui popover portals to <body>, where the dialog blocks pointer events.

The shadcn-app example shows every step.

Share light and dark mode

nxds owns the mode. shadcn/ui reads the .dark class through its @custom-variant dark, so it follows nxds without a change. next-themes stays for the components that read it, such as the shadcn/ui Toaster, and follows nxds too:

// components/theme-provider.tsx
'use client';

import { ThemeProvider as NextThemesProvider, useTheme } from 'next-themes';
import { useEffect } from 'react';

import { defaultAppearance } from '@/app/appearance-state';
import { useNxdsAppearance } from '@/components/nxds/components/appearance/provider';

function NextThemesFollowsNxds() {
  const { state, mounted } = useNxdsAppearance();
  const { setTheme } = useTheme();

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

  return null;
}

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme={defaultAppearance.mode}
      disableTransitionOnChange
    >
      <NextThemesFollowsNxds />
      {children}
    </NextThemesProvider>
  );
}

Put it inside the nxds provider:

// app/layout.tsx
import { NxdsAppearanceProvider } from '@/components/nxds/components/appearance/provider';
import { NxdsAppearanceScript } from '@/components/nxds/components/appearance/provider/server';
import { Toaster as NxdsToaster } from '@/components/nxds/components/sonner/sonner';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/sonner';

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

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

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NxdsAppearanceScript
          storageKey="app-appearance"
          defaultState={defaultAppearance}
        />
      </head>
      <body>
        <NxdsAppearanceProvider
          storageKey="app-appearance"
          defaultState={defaultAppearance}
        >
          <ThemeProvider>
            {children}
            <Toaster />
            <NxdsToaster />
          </ThemeProvider>
        </NxdsAppearanceProvider>
      </body>
    </html>
  );
}

next-themes uses 'light', 'dark' and 'system', like nxds. Its defaultTheme matches the nxds default, so a first visit paints one mode. If next-themes saved another mode before you added nxds, that mode can show for a moment on the first load. From then on, both save the same mode.

next-themes has no option to stop saving its own copy, and it syncs that copy across tabs. A toggle in another tab switches the .dark class here, and the nxds state catches up on the next reload.

Switch the mode through nxds only:

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

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

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

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

Don't call setTheme yourself. NextThemesFollowsNxds does it.

Match your theme

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

export const defaultAppearance = {
  ...DEFAULT_NXDS_APPEARANCE,
  brandColor: 'oklch(0.205 0 0)',
  surfaceTone: 'neutral',
  corners: 'smooth',
} satisfies NxdsAppearanceState;
shadcn/uiAppearance fieldHow to match
--primarybrandColorCopy the light value from globals.css. nxds builds the dark and hover shades
baseColor in components.jsonsurfaceToneSame names: neutral, stone, zinc, gray, slate
--radiuscorners0 is square, 0.25rem is subtle, 0.5rem is smooth, 0.75rem is round, 1rem is extra-round
--font-sansprefs.uiFontPass the same font stack
Nonedensity, elevation, strokeshadcn/ui has no setting for these, so keep the defaults
.dark in globals.cssmodenxds owns it, as Share light and dark mode shows

nxds derives the dark palette from the same fields. You don't copy the .dark block.

Where nxds fits

Most parts keep their shadcn/ui names. These change:

shadcn/uinxdsWhat changes
Form, FormField, FormItem, FormLabel, FormDescription, FormMessageField, FieldLabel, FieldDescription, FieldErrorWire your form library yourself. See Forms
CalendarDatePickerSame React Day Picker props
Toast, useToastToaster, toast()
Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContentEmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle, EmptyStateDescription, EmptyStateContent
Badge variant="destructive"Badge variant="error"
Badge variant="outline"Badge fill="outline"fill is separate from variant, so any status color can be outlined
Alert variant="destructive"Alert variant="destructive"Also success, warning and information
Dialog content between header and footerDialogBodyThe body scrolls while the header and footer stay put
Sheet and Drawer contentSheetBody, DrawerBody
The Combobox recipeCombobox, ComboboxTrigger, ComboboxContent, ComboboxInput, ComboboxList, ComboboxItemOne part per piece of the recipe
Data table rows with checkboxesTableSelectionHead, TableSelectionCell
Typography classesnx:typography-* classesSuch as nx:typography-heading-large, nx:typography-body-default

Rewrite classes

When you move a component into components/nxds, rewrite its classes:

shadcn/uinxdsRule
md:flexnx:md:flexnx: goes before every modifier
bg-primarynx:bg-primary-backgroundName the role, not only the color family
hover:bg-primary/90nx:hover:bg-primary-background-hoverUse the state token, not an opacity
hover:bg-accent on the pagenx:hover:bg-background-hoverThere is no accent. Pick the hover by surface
hover:bg-accent on a cardnx:hover:bg-container-hover
focus:bg-accent in a menunx:focus:bg-popover-hover
bg-cardnx:bg-container
bg-destructivenx:bg-error-backgroundThe prop stays destructive. The token is error
bg-sidebar, text-sidebar-foregroundnx:bg-nav-background, nx:text-nav-foreground
dark:bg-input/30Delete itSemantic tokens already change in dark mode

Send toasts

Import toast from the nxds sonner file to show a toast in the nxds Toaster:

import { toast } from '@/components/nxds/components/sonner/sonner';

toast.success('Saved');

nxds toasts render only in the nxds Toaster. Your shadcn/ui Toaster keeps showing the toasts you send with sonner's own toast.

Swap a component (optional)

Swap a component by changing its import. Rename props where the table above says so:

// Before
import { Badge } from '@/components/ui/badge';

<Badge variant="destructive">Failed</Badge>;
// After
import { Badge } from '@/components/nxds/components/badge/badge';

<Badge variant="error">Failed</Badge>;

To check the classes in the files you move over, add the nxds lint rules. They catch an unprefixed class, an opacity state and an accent token.

What stays on shadcn/ui

Keep these on shadcn/ui:

  • Blocks and community registry items. They use shadcn/ui token names.
  • Components you have edited a lot. Move them when you touch them anyway.
  • Form with react-hook-form, if you like its wiring. nxds Field works with any form library.