The upgrade was scheduled for a quiet week and produced a page where every column was three pixels lower than it had been and the sidebar was suddenly the full height of the content. Nothing was broken exactly. Everything was slightly different, and finding out why meant understanding that the grid is a different mechanism now.
The symptom
# a visual regression run across 40 templates
$ backstop test
✗ home.desktop 3.42% pixel difference
✗ product.desktop 1.18%
✗ checkout.desktop 11.90% ← the sidebar
✓ login.desktop
38 of 40 changed. none of them intentionally.Three percent is small enough to argue about and large enough that somebody will notice. The checkout page at twelve percent was the useful one, because it was obviously wrong rather than subtly, and it explained the rest.
Why it happens
Floated columns do not know about each other. Each one is laid out independently, takes the height of its content, and the row is cleared afterwards — which is why equal-height columns in Bootstrap 3 required either a JavaScript plugin or a table display hack.
Flexbox columns are siblings in a flex container and default to stretching to the height of the tallest. That single default explains almost every difference: the sidebar now matches the content column because it is told to, backgrounds fill their columns because the columns have height, and vertical alignment is a property rather than a workaround.
/* 3: the row clears, columns float */
.row::after { display: table; clear: both; content: ""; }
.col-md-6 { float: left; width: 50%; }
/* 4: the row is a flex container, columns are items */
.row { display: flex; flex-wrap: wrap; }
.col-md-6 { flex: 0 0 50%; max-width: 50%; }
/* which is why this now does something */
.row.align-items-center { align-items: center; }
The fix
The renames, which are mechanical
<!-- xs has no infix: it is the default tier -->
<div class="col-xs-12 col-md-6"> → <div class="col-12 col-md-6">
<!-- offsets -->
<div class="col-md-offset-2"> → <div class="offset-md-2">
<!-- helper text, panels, wells -->
<p class="help-block"> → <small class="form-text text-muted">
<div class="panel panel-default"> → <div class="card">
<div class="well"> → <div class="card card-body">
<!-- and the new one worth adopting immediately -->
<div class="col"></div><div class="col"></div> <!-- equal widths -->
These are a search and replace and a careful reading of the diff. The bare col for equal widths is genuinely new and removes the arithmetic from any row where the columns should simply share the space, which turns out to be most of them.
The ones that changed meaning rather than name
These are the ones that produce the pixel differences, because the class survived the upgrade and does something else now.
/* .row gutters moved from negative margins on the row to padding
on the column — so a custom .row wrapper that reset margins
now removes the gutters entirely. */
/* .container is no longer a fixed width per breakpoint; it has
max-width, which changes behaviour between breakpoints. */
/* .hidden-xs and friends are GONE, not renamed.
the replacement is display utilities: */
.hidden-xs → .d-none .d-sm-block
.visible-md-block → .d-none .d-md-block .d-lg-none
/* .pull-left / .pull-right → .float-left / .float-right
but inside a flex row, floats do nothing at all. */
The responsive visibility classes being removed rather than renamed is the one that caused the most work, because the replacement is a pair of classes expressing a range rather than one class expressing a breakpoint. Anything using them heavily is worth revisiting rather than translating, since the flex utilities frequently express the intent better than showing and hiding two versions of the same content.
Sass instead of Less, and customisation that works
The build moved to Sass, and with it the variable override mechanism became one that can be used without forking the framework.
// custom.scss — the order is load-bearing
@import "bootstrap/functions";
// your overrides, BEFORE bootstrap's variables
$primary: #2b6cb0;
$grid-gutter-width: 2rem;
$enable-shadows: false;
$theme-colors: map-merge($theme-colors, ("brand": #7b341e));
@import "bootstrap/variables"; // declared !default — yours win
@import "bootstrap/mixins";
// then only the components in use
@import "bootstrap/reboot";
@import "bootstrap/grid";
@import "bootstrap/utilities";
@import "bootstrap/buttons";
@import "bootstrap/forms";
@import "bootstrap/card";
Every Bootstrap variable is declared !default, which means it takes effect only if nothing has already set it — so overrides must come first. Getting the order wrong produces a build where the custom colours are silently ignored, and there is no warning of any kind.
Importing only what is used is the other half. The full stylesheet is around 150 kilobytes uncompressed and most projects use the grid, some utilities and four components; on one site the selective import took it to 39 kilobytes, almost all of the saving from components nobody had ever put on a page.
Note
map-merge on $theme-colors rather than reassigning it is what adds a colour while keeping the built-in ones. Reassigning removes danger and warning, and the failure appears as buttons with no styling on a page nobody checked.
The jQuery dependency that did not go away
Everything with behaviour — the modal, the dropdown, the collapse, the tooltip — is still a jQuery plugin, and Popper.js is now a second requirement for anything positioned.
<script src="jquery-3.3.1.slim.min.js"></script>
<script src="popper.min.js"></script>
<script src="bootstrap.min.js"></script>
# the slim build has no ajax and no effects, which Bootstrap does
# not need — and which your own code may well be using.The slim jQuery build is what the documentation recommends and it omits $.ajax, which is used by roughly every legacy script on a site of any age. Switching to it and finding out at runtime is a bad afternoon; grepping first takes a minute. For a project that has otherwise removed jQuery, the practical options are to keep it for Bootstrap alone or to reimplement the two or three components in use, and the second is more work than it sounds because the accessibility behaviour is not trivial.
Verifying it worked
$ backstop test
✓ 40 of 40
$ ls -la public/css/
-rw-r--r-- 39114 app.css # was 148,204
# and the check that matters more than any of it:
# every interactive component, on a touch device, with a keyboard
# modal open/close/focus trap
# dropdown with arrow keys
# collapse on the mobile navThe visual diff being clean is necessary and not sufficient, because a screenshot cannot tell you whether the modal traps focus. Testing the components by hand once, properly, is the step that catches the class of problem where the markup was translated correctly and the behaviour depends on an attribute that was not.
What this costs
A design system built on 3 has assumptions the upgrade breaks, and the deepest of them is that columns do not have height. Any component that positioned something absolutely inside a column, or relied on the column collapsing to its content, needs revisiting rather than translating — and on a mature codebase there are more of those than anyone expects.
The larger question is whether the upgrade is worth doing at all on a site that works. The honest answer is that flexbox and the utility classes remove real code, and that Bootstrap 3 stopped receiving anything but security fixes — so the decision is when rather than whether. Doing it alongside a redesign is dramatically cheaper than doing it on its own, because the pixel differences stop being regressions and become someone’s intention.