React – Bootstrap

August 14, 20265 min readUpdated 8/18/2026

Bootstrap gives you a grid, a component library and a set of utility classes. There are two ways to use it from React, and picking the right one — plus knowing the one thing you must not do — is most of this post.

Two ways

1. The stylesheet, with className

npm install bootstrap
// main.tsx
import 'bootstrap/dist/css/bootstrap.min.css';

Then Bootstrap's classes work as they do in plain HTML:

<div className="d-flex justify-content-between align-items-center mt-2">
  <span className="fw-bold">from {formatMoney(cheapest)}</span>
</div>

This is the whole story for layout and utilities, which have no behaviour attached.

2. react-bootstrap, for the interactive components

npm install react-bootstrap bootstrap
import { Button, Card, Container, Modal, Nav, Navbar, Offcanvas } from 'react-bootstrap';

These are real React components — no jQuery, no data attributes, state controlled by props. That matters for anything with behaviour: modals, dropdowns, offcanvas drawers, tooltips, accordions.

Do not load Bootstrap's JavaScript bundle

This is the mistake worth avoiding. Bootstrap ships its own JS for dropdowns and modals, and it works by finding elements in the DOM and manipulating them directly. React also owns that DOM. Two systems mutating the same nodes produces bugs that are extremely hard to trace — a modal that will not close, a dropdown that reopens itself, event handlers firing twice.

// Do NOT do this alongside react-bootstrap.
import 'bootstrap/dist/js/bootstrap.bundle.min.js';

Import the stylesheet only, and let react-bootstrap provide the behaviour. That is its entire reason to exist.

Controlled components

This is where react-bootstrap differs most from the plain library. In vanilla Bootstrap you toggle a modal with a data attribute. In React, its visibility is state:

// App.tsx — the state lives here because the navbar opens it and the drawer consumes it.
const [cartOpen, setCartOpen] = useState(false);

<AppNavbar onOpenCart={() => setCartOpen(true)} />
<CartDrawer show={cartOpen} onHide={() => setCartOpen(false)} />
export function CartDrawer({ show, onHide }: { show: boolean; onHide: () => void }) {
  return (
    <Offcanvas show={show} onHide={onHide} placement="end">
      <Offcanvas.Header closeButton>
        <Offcanvas.Title>Your order</Offcanvas.Title>
      </Offcanvas.Header>
      <Offcanvas.Body className="d-flex flex-column">
        {/* … */}
      </Offcanvas.Body>
    </Offcanvas>
  );
}

show and onHide are the pattern for every overlay in the library — the same value/onChange shape as a controlled input. Your state is the truth; the component renders it.

You get a meaningful amount of accessibility work for free here. Offcanvas handles the backdrop, the Escape key and focus trapping; Modal the same, plus restoring focus on close. Writing that yourself correctly is a genuine effort.

The modal in the pizza builder uses state for "is it open" too, but in a shape worth copying — one variable instead of two:

// `null` means the modal is closed. One piece of state, not two.
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);

<PizzaBuilderModal product={selectedProduct} onHide={() => setSelectedProduct(null)} />
<Modal
  show={product !== null}
  onHide={onHide}
  size="lg"
  centered
  scrollable
  onEntered={() => confirmButtonRef.current?.focus()}
  aria-labelledby="builder-title"
>

onEntered fires after the open animation, which is the right moment to move focus — see Refs.

Compound components

Most of the library uses dotted sub-components, which keeps the markup close to Bootstrap's own class structure:

<Card className="product-card">
  <Card.Body className="d-flex flex-column">
    <Card.Title as="h3" className="h6 fw-bold mb-1">
      {product.name}
    </Card.Title>
    <Card.Text className="text-muted small flex-grow-1">{product.description}</Card.Text>
  </Card.Body>
</Card>

as="h3" renders the title as a real heading rather than a div. Use it — heading structure is how screen-reader users navigate a page, and a card title that is not a heading is invisible to them.

The `as` prop, and routing

as also composes react-bootstrap with React Router — Bootstrap's styling, Router's navigation, one tag:

<Navbar.Brand as={Link} to="/" className="pizza-brand">
  Pizza<span>Hub</span>
</Navbar.Brand>

<Nav.Link as={NavLink} to="/menu" end>
  Menu
</Nav.Link>

<NavDropdown.Item as={Link} to="/profile">
  Profile
</NavDropdown.Item>

One place it does not work, and it is worth knowing before you spend twenty minutes on it:

/*
 * react-bootstrap's <Button as={Link}> does not typecheck in v2 — its `as` prop is typed against
 * intrinsic elements, so passing Router's Link fails. Rendering a <Link> with Bootstrap's own
 * `btn` classes produces identical markup and styling with no casts, and it stays semantically
 * correct: these navigate somewhere, so they should be anchors, not buttons.
 */
<Link to="/menu" className="btn btn-primary btn-lg">
  Order now
</Link>

Which is the better answer anyway. Something that navigates should be a link, not a button — for middle-click, for "open in new tab", and for anyone using a screen reader.

Theming

Bootstrap 5.3 publishes its design tokens as CSS custom properties, so retuning it means redefining variables rather than fighting specificity:

:root {
  --pizza-red: #d8102a;
  --pizza-red-dark: #a80d21;

  /* Bootstrap token overrides — every btn-primary, link and focus ring follows these. */
  --bs-primary: var(--pizza-red);
  --bs-primary-rgb: 216, 16, 42;
  --bs-link-color: var(--pizza-red);
  --bs-link-hover-color: var(--pizza-red-dark);
  --bs-body-font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

One catch: components define their own scoped variables, so setting --bs-primary does not reach .btn-primary. Buttons need their own set restated:

/* Bootstrap's .btn-primary reads its own set of variables, so it needs them restated. */
.btn-primary {
  --bs-btn-bg: var(--pizza-red);
  --bs-btn-border-color: var(--pizza-red);
  --bs-btn-hover-bg: var(--pizza-red-dark);
  --bs-btn-hover-border-color: var(--pizza-red-dark);
  --bs-btn-active-bg: var(--pizza-red-dark);
  --bs-btn-active-border-color: var(--pizza-red-dark);
  --bs-btn-disabled-bg: var(--pizza-red);
  --bs-btn-disabled-border-color: var(--pizza-red);

  font-weight: 700;
}

Redefining variables rather than overriding properties means hover, active, focus and disabled all follow automatically. Not one !important in the whole theme.

--bs-primary-rgb exists separately because Bootstrap composes translucent colours as rgba(var(--bs-primary-rgb), 0.25). Set one and not the other and focus rings keep the old colour.

For a deeper change — different spacing, different breakpoints, a smaller build with unused components dropped — you need to recompile Bootstrap's own Sass. That is the next post.

Bundle size

The full stylesheet is about 233 kB, 32 kB gzipped, and react-bootstrap tree-shakes so you only ship the components you import. Real numbers from this app's build:

dist/assets/index--mWIANA6.css   233.62 kB │ gzip:  32.02 kB
dist/assets/Table-YEv3Zq6I.js     17.56 kB │ gzip:   6.16 kB   ← only in the admin chunk

Note where Table ended up. It is imported only by admin pages, which are lazy-loaded, so it is in a lazy chunk rather than the entry bundle. Import boundaries determine bundle boundaries.

The CSS is the bigger number, and getting it down means compiling only the parts of Bootstrap you use — again, Sass.

Alternatives

Bootstrap is a reasonable default when you want a complete, familiar, accessible component set and do not want to design one. If you want unstyled, accessible primitives to style yourself, look at Radix or React Aria; if you want Material Design, MUI; if you want utilities without components, Tailwind.

Next

Sass — the last lesson.