Skip to main content
Nexus Design System
GitHub

nxds with Bootstrap

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

What nxds adds for you

nxds gives a Bootstrap app React components with their behavior built in, themed from one appearance object. Introduction covers the theme engine, the copy-and-own source and the agent support.

You neednxds part
A searchable selectCombobox
A select with several valuesMultiSelect
A date pickerDatePicker
A command paletteCommandDialog
A collapsible app sidebarSidebar and SidebarProvider
A filter barFilterBuilder
A right-click menu or menu barContextMenu and Menubar
A chartChartContainer
A one-time code inputInputOTP

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. Bootstrap's Reboot sets box-sizing, so delete the Preflight lines in app/nxds.css, as Add nxds to an existing app explains.

Bootstrap's CSS sits outside any cascade layer, so Reboot's button { border-radius: 0 } would beat an nxds button's corners. Load Bootstrap into a layer below the nxds utilities:

/* app/globals.css */
@layer bootstrap, properties, theme, base, components, utilities, nx-runtime;
@import 'bootstrap/dist/css/bootstrap.min.css' layer(bootstrap);

Import globals.css in app/layout.tsx instead of the Bootstrap stylesheet. If you build Bootstrap from Sass, wrap your compiled CSS the same way. Your own CSS outside a layer now beats every Bootstrap rule, whatever its specificity.

Bootstrap classes and nxds classes never collide. d-flex and mt-3 are Bootstrap's, and nx:flex and nx:mt-3 are nxds's, so they read side by side:

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

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

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

/* app/nxds.css, after the imports */
:root {
  --nx-z-index-modal: 1100;
  --nx-z-index-popover: 1200;
  --nx-z-index-toast: 1300;
}

Bootstrap dropdowns render in place, so they work inside an nxds dialog. If you use React Bootstrap, pass container to Overlay and OverlayTrigger to keep popovers and tooltips in place.

Share light and dark mode

nxds owns the mode. Bootstrap reads data-bs-theme on <html>, so set it from the .dark class that nxds writes. One line in <head> sets it before the first paint, after the nxds script:

// app/layout.tsx
import { NxdsAppearanceProvider } from '@/components/nxds/components/appearance/provider';
import { NxdsAppearanceScript } from '@/components/nxds/components/appearance/provider/server';

import { defaultAppearance } from './appearance-state';
import { BootstrapFollowsNxds } from './bootstrap-follows-nxds';

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

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

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

If you pass a nonce to NxdsAppearanceScript, pass the same one to the Bootstrap script.

BootstrapFollowsNxds keeps the attribute in step when the mode changes:

// app/bootstrap-follows-nxds.tsx
'use client';

import { useEffect } from 'react';

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

export function BootstrapFollowsNxds() {
  const { resolvedMode, mounted } = useNxdsAppearance();

  useEffect(() => {
    if (mounted) document.documentElement.dataset.bsTheme = resolvedMode;
  }, [mounted, resolvedMode]);

  return null;
}

Bootstrap has no saved mode and no system mode of its own. It follows resolvedMode, so nxds 'system' works without a change.

Switch the mode through nxds:

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

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

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

  return (
    <button
      type="button"
      className="btn btn-outline-secondary"
      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 defaultAppearance = {
  ...DEFAULT_NXDS_APPEARANCE,
  brandColor: '#0d6efd',
  surfaceTone: 'gray',
  corners: 'smooth',
  elevation: 'flat',
} satisfies NxdsAppearanceState;
BootstrapAppearance fieldHow to match
$primary, --bs-primarybrandColorPass it as is. Bootstrap's default is #0d6efd
$gray-100 to $gray-900surfaceTonegray is closest to Bootstrap's grays
$border-radiuscorners0 is square, .25rem is subtle, .375rem and .5rem are smooth, .75rem is round, 1rem is extra-round
$spacerdensityCompare in the Playground
$enable-shadows, $box-shadowelevationBootstrap turns shadows off by default, so flat matches
$border-widthstroke1px is normal, 2px is strong
$font-family-sans-serifprefs.uiFontPass the same font stack
$font-size-baseprefs.uiFontSizeBootstrap's default is 16 and nxds's is 14. Set 16 to match Bootstrap's text
data-bs-thememodenxds owns it, as Share light and dark mode shows

Where nxds fits

Each row maps a Bootstrap class, and its React Bootstrap component, to the nxds parts that replace it.

BootstrapnxdsWhat changes
.btn-primary, .btn-secondary, .btn-outline-*, .btn-linkButton variant default, secondary, outline, link
.btn-dangerButton variant="destructive"
.btn-sm, .btn-lgButton size="sm", size="lg"
.btn-groupButtonGroup
.form-label, .form-control, .form-textField, FieldLabel, Input, FieldDescription
.is-invalid, .invalid-feedbackdata-invalid on Field, aria-invalid on the input, FieldError
textarea.form-controlTextarea
.input-group, .input-group-textInputGroup, InputGroupAddon, InputGroupText, InputGroupInput
.form-selectNativeSelect, or Select for a styled listSelect takes onValueChange(value)
.form-check with a checkboxCheckbox in a Field orientation="horizontal"onCheckedChange(checked) instead of onChange(event)
.form-check with radiosRadioGroup, RadioGroupItemonValueChange(value)
.form-switchSwitchonCheckedChange(checked)
.form-rangeSlidervalue is an array. onValueChange(value)
.btn-check button groupsToggleGroup, ToggleGroupItem
.modal (Modal)Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooterNo data-bs-toggle: DialogTrigger opens it
.offcanvas (Offcanvas)Sheet, SheetContent.offcanvas-start → side="left", .offcanvas-end → side="right"
.dropdown (Dropdown)DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
.popover, .tooltip (Popover, Tooltip)Popover, PopoverTrigger, PopoverContent, Tooltip, TooltipTrigger, TooltipContentNo data-bs-toggle and no JavaScript setup
.toast (Toast)Toaster, toast()Render <Toaster /> once, then call toast('Saved')
.alert-danger, .alert-warning, .alert-info, .alert-successAlert variant destructive, warning, information, successAlertTitle and AlertDescription hold the text
.accordion (Accordion)Accordion, AccordionItem, AccordionTrigger, AccordionContent
.collapse (Collapse)Collapsible, CollapsibleTrigger, CollapsibleContent
.nav-tabs, .nav-pills with .tab-pane (Tabs)Tabs, TabsList, TabsTrigger, TabsContent.nav-tabs → variant="underline"
.navbar linksNavigationMenu
.breadcrumbBreadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage
.paginationPagination, PaginationContent, PaginationItem, PaginationLink
.card, .card-header, .card-body, .card-footerCard, CardHeader, CardContent, CardFooter.card-title → CardTitle, .card-text → CardDescription
.list-group, .list-group-itemItemGroup, Item
.badge with .text-bg-*Badge.text-bg-danger → variant="error", .text-bg-success → variant="success"
.progressProgress
.spinner-borderSpinner
.placeholderSkeleton
.tableTable, TableHeader, TableBody, TableRow, TableHead, TableCell
.carousel (Carousel)Carousel, CarouselContent, CarouselItem
.ratioAspectRatio
hr, .vrSeparatororientation="vertical" for .vr
.d-flex, .gap-3, .row, .colnx: flex and grid classesAt the default density, .gap-3 → nx:gap-4
.h1 to .h6, .display-*, .leadnx:typography-* classesSuch as nx:typography-heading-large and nx:typography-body-default
.d-none .d-md-blockShow and Hide<Show above="md"> shows content from the md breakpoint up
.visually-hiddennx:sr-only

Swap a component (optional)

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

// Before
<div className="mb-3">
  <label htmlFor="email" className="form-label">
    Email
  </label>
  <input
    id="email"
    type="email"
    className={error ? 'form-control is-invalid' : 'form-control'}
  />
  <div className="form-text">We never share it.</div>
  <div className="invalid-feedback">{error}</div>
</div>
// 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 Bootstrap

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

  • Floating labels (.form-floating)
  • Scrollspy
  • A navbar that collapses into a menu button (.navbar-expand-*)
  • .figure and .stretched-link
  • The grid for page layout, if you like it. nxds components fit inside it
  • Bootstrap Icons. nxds components take any icon as a child