You might not need Bourbon

Over 13 years ago, thoughtbot introduced Bourbon to the open-source community. At a time when Sass was gaining momentum, our developers and designers created a library of useful mixins to accelerate development across projects. As the library expanded, we began managing versioning and distribution through Git and a Ruby gem. Bourbon quickly claimed the spot of the most GitHub stars among all our open source projects, and garnered significant attention over the years.

Today, many of the features Bourbon once provided are now achievable with native CSS. This evolution is a positive step, allowing us to rely on standardized web capabilities rather than external dependencies. While Bourbon still serves specific use cases, it has largely become obsolete as native CSS simplifies our workflow and enhances the sustainability of our projects. Let’s explore how to replace each helper with modern CSS features.

Directional border helpers

All of the border helpers (border-color, border-top-radius, border-right-radius, border-bottom-radius, border-left-radius, border-style, and border-width) in Bourbon provide a succinct way to define color, radii, style, and width for specific sides of an element when you’re dealing with multiple directions.

.element {
  @include border-width(1em null 20px);
}

// CSS output
.element {
  border-bottom-width: 20px;
  border-top-width: 1em;
}

Right off the bat, I don’t have a clever replacement for this until CSS gets native mixins. These properties can also be written out the shorthand way, defining each value in clockwise order (top, right, bottom, left):

.element {
  border-width: 1px 2px 10px 2px;
}

Which is the same as this:

.element {
  border-top-width: 1px;
  border-right-width: 2px;
  border-bottom-width: 10px;
  border-left-width: 2px;
}

Shorthand can also be used to define horizontal and vertical values:

.element {
  border-width: 1px 10px;
}

Which is the same as this:

.element {
  border-top-width: 1px;
  border-right-width: 10px;
  border-bottom-width: 1px;
  border-left-width: 10px;
}

What’s not provided in native CSS shorthand is the null value in the Bourbon mixin that allows you to omit a direction. And while that is a handy feature, I’d argue if you’re doing anything complex with varying values for each direction, it’s likely better to be more verbose in your code so it’s clear which value is affecting which direction.

Additionally, this might also be a good place for a custom property if you’re using the same values in different elements:

:root {
  --border-width-base: 1px;
  --border-width-large: 10px;

  --border-color-base: #eee;
  --border-color-fancy: #b4da55;
}

.element {
  border-top: var(--border-width-base) solid var(--border-color-base);
}

.elementfancy {
  border: var(--border-width-large) solid var(--border-color-fancy);
}

Button and input element lists

The all buttons (all-buttons, all-buttons-active, all-buttons-focus, all-buttons-hover) and all text inputs helpers (all-text-inputs, all-text-inputs-active, all-text-inputs-focus, all-text-inputs-hover, all-text-inputs-invalid) select elements that fall within those categories by their attribute and their associated pseudo class. These elements are often defined together when styling forms and their interactive states.

#{$all-text-inputs-focus} {
  border: 1px solid #1565c0;
}

// CSS output
[type='color']:focus,
[type='date']:focus,
[type='datetime']:focus,
[type='datetime-local']:focus,
[type='email']:focus,
[type='month']:focus,
[type='number']:focus,
[type='password']:focus,
[type='search']:focus,
[type='tel']:focus,
[type='text']:focus,
[type='time']:focus,
[type='url']:focus,
[type='week']:focus,
input:not([type]):focus,
textarea:focus {
  border: 1px solid #1565c0;
}

The :is pseudo-class allows us to write out these selector lists with their states in a similar way. You still have to actually write out the starting selectors, but once again, the argument that erring on the side of verbosity is usually more clear.

In this example we can target the :focus state of our selector list by appending it after our :is argument.

:is(
  input:not([type]),
  select,
  textarea,
  [type="color"],
  [type="date"],
  [type="datetime"],
  [type="datetime-local"],
  [type="email"],
  [type="month"],
  [type="month"],
  [type="number"],
  [type="password"],
  [type="search"],
  [type="tel"],
  [type="text"],
  [type="time"],
  [type="url"],
  [type="week"]
):focus {
  border: 1px solid #1565c0;
}

You can also achieve this with CSS nesting:

input:not([type]),
select,
textarea,
[type="color"],
[type="date"],
[type="datetime"],
[type="datetime-local"],
[type="email"],
[type="month"],
[type="month"],
[type="number"],
[type="password"],
[type="search"],
[type="tel"],
[type="text"],
[type="time"],
[type="url"],
[type="week"] {
  &:focus {
    border: 1px solid #1565c0;
  }
}

Yet another way is to use the :where pseudo-class. The syntax is identical to the :is pseudo-class, but it has 0 specificity. This means that unlike the previous 2 examples, you can override the declarations later in the cascade if needed.

:where(
  input:not([type]),
  select,
  textarea,
  [type="color"],
  [type="date"],
  [type="datetime"],
  [type="datetime-local"],
  [type="email"],
  [type="month"],
  [type="month"],
  [type="number"],
  [type="password"],
  [type="search"],
  [type="tel"],
  [type="text"],
  [type="time"],
  [type="url"],
  [type="week"]
):focus {
  border: 1px solid #1565c0;
}

Now I want to override the focus border color just for [type="email"] later on in the cascade. With :where, it’ll take on this new styling (which it won’t with :is and nesting):

[type="email"]:focus {
  border-color: orange;
}

Clearfix

With features like flexbox and grid, there’s not a lot of need for using floats to create page layouts. And thus, not a huge need for the clearfix hack (which prevented a parent with floated children from collapsing its height). The mixin version provided in Bourbon is a short set of properties to write out manually if needed for one-off situations:

.element::after {
  clear: both;
  content: "";
  display: block;
}

Contrast-switch

The contrast-switch helper will output a lighter or darker foreground color depending on the contrast ratio with the background color.

.element {
  $button-color: #2d72d9;
  background-color: $button-color;
  color: contrast-switch($button-color, #222, #eee);
}

// CSS output
.element {
  background-color: #2d72d9;
  color: #eee;
}

This doesn’t have a native CSS fix …yet. The good news is there’s an experimental function that, if launched in browsers, would replace this helper.

One consideration is to eliminate the need for this by building a palette and style guide that defines these color combinations, maintaining compliant contrast.

Ellipsis

Ellipsis is a mixin that allows you to truncate text and append an ellipsis when it overflows.

.element {
  @include ellipsis;
}

// CSS output
.element {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  word-wrap: normal;
}

A newer way to achieve this uses a property called line-clamp. This is a more intentional way (as opposed to its hack predecessor) of achieving even more flexible results, automatically appending the ellipses after defining the amount of lines you want to show.

Do note that this way requires the -webkit prefix (which is well-supported) and a display value of -webkit-box.

In this example we want to truncate with an ellipses after 2 lines of text:

.element {
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  display: -webkit-box;
  overflow: hidden;
}

Font-face

Bourbon’s font-face mixin allows you to output a block of declarations to define font family names and file paths in a @font-face rule.

@include font-face(
  "source-sans-pro",
  "fonts/source-sans-pro-regular",
  ("woff2", "woff")
) {
  font-style: normal;
  font-weight: 400;
}

// CSS output
@font-face {
  font-family: "source-sans-pro";
  src: url("fonts/source-sans-pro-regular.woff2") format("woff2"),
       url("fonts/source-sans-pro-regular.woff") format("woff");
  font-style: normal;
  font-weight: 400;
}

This mixin’s best utility allows you to pass all the font extensions you’re using without writing out each file path (woff, woff2, ttf, etc.). Today, all modern browsers support woff2 fonts, so the array here is no longer needed. Simply put, writing it out in native CSS now isn’t any more intense than what the mixin offers.

In this example, we’re using the font-display property to ensure the fallback fonts display in the browser while any custom fonts are still downloading. Additionally, this is a variable font, so we define a range of font weights instead of one number (which also saves us from declaring more font files).

@font-face {
  font-display: swap;
  font-family: "WorkSans";
  font-style: normal;
  font-weight: 100 900;
  src: url("/static/fonts/work-sans.woff2") format("woff2");
}

Font stacks

The font stacks (font-stack-helvetica, font-stack-verdana, font-stack-system, font-stack-garamond, font-stack-hoefler-text, font-stack-consolas, font-stack-courier-new, font-stack-monaco) are variables that define a list of web-safe font families and their fallbacks.

.element {
  font-family: $font-stack-helvetica;
}

// CSS Output
.element {
  font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
}

You can refer to these font stacks (or better yet, create your own list of web-safe fonts) and use a custom property to define them.

:root {
  --font-sans-stack: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
  --font-serif-stack: "Garamond", "Baskerville", "Baskerville Old Face", "Hoefler Text", "Times New Roman", serif;
  --font-body: "Work Sans", var(--font-sans-stack);
  --font-display: "Merriweather", var(--font-serif-stack);
}

body {
  font-family: var(--font-body);
}

h1,
h2,
h3 {
  font-family: var(--font-display);
}

Hide-text

The hide-text helper is yet another legacy CSS hack, using a text indent to hide text, usually in favor of an image. Generally, I’d say avoid this hack, especially since it won’t localize to RTL languages.

.element {
  overflow: hidden;
  text-indent: 101%;
  white-space: nowrap;
}

If you’re using this to show an image that looks like text, you can instead either:

If you’re using hidden text to describe an actual image, you can instead:

Hide-visually

Visually hiding an element allows you to remove an element from the display but still make it accessible in the DOM to screen readers. This can be useful for describing landmarks on the page for navigation without having to actually style them within your layout. However, hiding elements for some users and not for others, even with the best intentions, is a complex discussion.

.element {
  border: 0;
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(100%);
  height: 1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}

In his article on visually hidden content, Scott O’Hara notes:

Remember, not all screen reader users are completely blind. Some of these users, for instance, might be able to see or partially see a custom checkbox on their iOS or Android device. They might then think to try and press or drag their finger across their screen to “explore by touch” while the screen reader is enabled. However, they’d then be seemingly unable find this checkbox that visually appears to be there, but is really shoved off into a 1px by 1px square somewhere on the page, if not entirely positioned off screen. Fun! Good luck finding that 1px by 1px checkbox that might not even be discoverable in the viewport.”

The main takeaway from this is to really think about why you might need to visually hide an element. Is it simply to tidy any clutter? Would it break the layout to surface that information to all viewers? Can we instead leverage attributes (which also have their own pitfalls) like aria-label and aria-description?

Margin and padding

Much like the directional border helpers, Bourbon’s margin and padding mixins define directional spacing while also allowing you to null a side, effectively skipping it within the shorthand.

.element {
  @include margin(10px 3em 20vh null);
}

// CSS output
.element {
  margin-bottom: 20vh;
  margin-right: 3em;
  margin-top: 10px;
}
.element-one {
  @include padding(null 1rem);
}

// CSS output
.element-one {
  padding-left: 1rem;
  padding-right: 1rem;
}

Margins are often used to define regular spacing between elements. Instead of applying a margin to each element (or using the axiomatic owl method), set up a layout grid with flexbox or grid and use the gap property to define predictable gutters both horizontally and vertically.

.parent-element {
  display: grid;
  gap: 1rem 20vw;
  grid-template-columns: 1fr 3fr 1fr 1fr;
  grid-template-rows: repeat(4, 10vh);
}

The padding mixin also defines spacing, but is more commonly used within the confines of an element instead of outside of it. You might still use the gap property, especially if you’re taking advantage of subgrid or are managing element layout with flexbox. You can also set up predictable spacing both between and within elements by using custom properties and applying them throughout your CSS:

:root {
  --space-base: 1.5rem;
  --space-small: 1rem;
  --space-medium: 2rem;
  --space-large: 5rem;
}

.element {
  padding: var(--space-base) var(--space-small);
}

.layout {
  display: grid;
  gap: var(--space-base);
  grid-template-columns: repeat(3, 1fr);
}

Modular-scale

Modular-scale defines a ratio to increment a value consistently, allowing you to create spatial relationships within a scale. This is particularly useful for making a typographical scale, setting a baseline size as well as larger and smaller sizes, all while falling along the same ratio (e.g. a header might always be 3 times larger than the baseline size).

.element {
  font-size: modular-scale(2);
}

// CSS output
.element {
  font-size: 1.5625em;
}

Creating type scales has now grown into developing responsive type systems. The clamp function allows us to linearly scale any size, including font size according to the viewport. The function takes arguments where you define a minimum value, the preferred value, and the maximum value:

h1 {
  font-size: clamp(1.5rem, 3vw, 4.2rem);
}

You can also insert math within this function:

p {
  font-size: clamp(1.25rem, 1.08rem + 0.5vw, 1.5rem);
}

Sites like utopia.fyi have a generator where you can configure a full type scale for yourself:

:root {
  /* Step 0: 18px → 20px */
  --step-0: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);

  /* Step 1: 21.6px → 25px */
  --step-1: clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem);

  /* Step 2: 25.92px → 31.25px */
  --step-2: clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem);

  /* Step 3: 31.104px → 39.0625px */
  --step-3: clamp(1.944rem, 1.771rem + 0.8651vw, 2.4414rem);

  /* Step 4: 37.3248px → 48.8281px */
  --step-4: clamp(2.3328rem, 2.0827rem + 1.2504vw, 3.0518rem);
}

If you’d like to recreate the functionality of Bourbon’s mixin, you can use the pow math function in CSS, which returns the value of a based raised to the power of that number:

:root {
  --scale-base: 1em;
  --scale-ratio: 1.25;

  --font-size-base: calc(var(--scale-base) * pow(var(--scale-ratio), 1));
  --font-size-small: calc(var(--scale-base) * pow(var(--scale-ratio), 0.5));
  --font-size-medium: calc(var(--scale-base) * pow(var(--scale-ratio), 2));
  --font-size-large: calc(var(--scale-base) * pow(var(--scale-ratio), 4));
}

body {
  font-size: var(--font-size-base);
}

h1 {
  font-size: var(--font-size-large);
}

h2 {
  font-size: var(--font-size-medium);
}

small {
  font-size: var(--font-size-small);
}

Overflow-wrap

Overflow-wrap supported browsers that didn’t yet use overflow-wrap with the legacy property of word-wrap. All modern browsers now support overflow-wrap so this mixin is no longer needed.

If this wasn’t well supported and we needed to provide a fallback for this property, a more optimized way would use the @supports feature query:

.element {
  word-wrap: break-word;
}

@supports (overflow-wrap: break-word) {
  .element {
    overflow-wrap: break-word[
  }
}

Position

For setting an element’s position properties, Bourbon provides a one-liner mixin, position, to set that value along with the placement relative to the top, right, bottom, and left sides.

.element {
  @include position(relative, 0 null null 10em);
}

// CSS output
.element {
  left: 10em;
  position: relative;
  top: 0;
}

There is no perfect replacement for this apart from using a custom property for the side values. However, you may find yourself needing to define placement less and less with layout options such as flexbox and grid. The mechanics of these layouts allow you to position items along an axis with properties like align-content, align-items, align-self, justify-content, justify-items, and justify-self. You can even use grid to overlap elements in a similar manner to absolute positioning.

These two child elements (a and b) will overlap:

.container {
  display: grid;
}

.element-a {
  grid-area: 1 / 1;
}

.element-b {
  grid-area: 1 / 1;
}

Another new feature that is still experimental (so not fully supported yet) is anchor positioning, allowing you to tether elements together.

Prefixer and value-prefixer

Vendor prefixes allow support for newer properties across different browsers before the implementation is standardized across those browsers. The prefixer and value-prefixer mixins generated those for any defined property or value, saving you the time of writing them all out.

.element {
  @include prefixer(appearance, none, ("webkit", "moz"));
}

// CSS output
.element {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
}
.element {
  @include value-prefixer(cursor, grab, ("webkit", "moz"));
}

// CSS output
.element {
  cursor: -webkit-grab;
  cursor: -moz-grab;
  cursor: grab;
}

While prefixes are still used for some properties, they’re not as common as they once were. You can check caniuse.com for support of certain CSS features. Additionally, a plugin like autoprefixer automatically outputs recommended vendor prefixes from your CSS.

Shade and tint

The shade and tint functions mix a color with a percentage black or white, respectively. Some folks may use this for developing a relational color palette. For example you might use a shade of your base button color when in a hover state.

.element {
  background-color: shade(#ffbb52, 60%);
}

// CSS output
.element {
  background-color: #664a20;
}

Before we dive into the native CSS replacement, I’ll argue that these functions often result in color palettes with far too many gradations of the same color. For example button A has a base color of blue and a tint of 30% while button B has a base color blue and a tint of 50%, meanwhile element C has a border color of blue with a shade of 10%. While the colors all tie back to that base blue color, the result is an undefined set of variations. Is the lighter version of blue a 20% tint or a 22% tint? What’s the use case for these variations? For that reason, I’d suggest being explicit about your color palette and defining the exact hex or rgb (or other color space) value in lieu of a function output.

With this caveat in mind, we can take advantage of CSS Colors Module Level 4 to replace these Bourbon functions! The new spec allows us to define hues, saturation, lightness, etc. across different color spaces.

The color-mix function is actually from the Level 5 Module and is well-supported across browsers. Much like the Bourbon functions, it takes two colors and mixes them by a percentage and according to the defined color space. We can use this to make dark and light versions of a base color:

:root {
  --base-color: #7df9ff;
  --light-color: color-mix(in srgb, var(--base-color), white 75%);
  --dark-color: color-mix(in srgb, var(--base-color), black 50%);
}

.element-base {
  background-color: var(--base-color);
}

.element-light {
  background-color: var(--light-color);
}

.element-dark {
  background-color: var(--dark-color);
}

Alternatively, you can use math functions with calc to calculate the outputs.

:root {
  --base-color: #7df9ff;
  --light-color: rgb(from var(--base-color) calc(r + 100) calc(g + 100) calc(b + 100));
  --dark-color: rgb(from var(--base-color) calc(r - 100) calc(g - 100) calc(b - 100));
}

.element-base {
  background-color: var(--base-color);
}

.element-light {
  background-color: var(--light-color);
}

.element-dark {
  background-color: var(--dark-color);
}

Size

The size mixin defines a width and height. You can also just set one number and it’ll set that number for both the width and height.

.first-element {
  @include size(2em);
}

// CSS output
.first-element {
  width: 2em;
  height: 2em;
}

You can use a custom property either globally or scoped to an element for this:

.element {
  --size: 3rem;
  height: var(--size);
  width: var(--size);
}

Additionally, that size units are no longer constrained to pixels, rems, ems, and percentages. We can set sizes relative to the viewport with vw, vh, vmin, and vmax; relative to a container with cqh, cqi, cqmax, and cqmin; using intrinsic size with min-content, max-content, and fit-content; with math functions like calc; with comparison functions like min, max, and clamp. You might also not need to set explicit widths and heights if your element is within a grid and defined with a fractional unit fr.

For elements with width and height relative to each other, you can use the aspect-ratio property to define that ratio. This ratio will be maintained no matter the size of the viewport:

.element {
  aspect-ratio: 16 / 9;
}

Strip-unit

Strip-unit is a function that returns a unitless number.

$dimension: strip-unit(10em);

// Output
$dimension: 10;

This was useful for scss calculations that required a variety of units (e.g. 1px * 2rem - 20%). Thankfully calc does a very good job of doing math with different units, including custom properties, so you shouldn’t have to strip or convert numbers for most use cases. Beyond calc, native CSS has a variety of advanced math functions to output stepped values, calculate trigonometric and exponential equations, and more!

.element {
  width: calc((10vw * 1.5rem) - var(--space-base));
}

Triangle

If you’re frequently creating tooltips or anything speech-bubble-shaped, you’ll be generating a triangle to anchor that bubble to a specific element. The triangle mixin does just that while also positioning it in a specific direction.

.element {
  &::before {
    @include triangle("up", 2rem, 1rem, #b25c9c);
    content: "";
  }
}

// CSS output
.element::before {
  border-style: solid;
  height: 0;
  width: 0;
  border-color: transparent transparent #b25c9c;
  border-width: 0 1rem 1rem;
  content: "";
}

In native CSS, the polygon function outputs any cornered shape, based on pairs of coordinates. Along with clip-path, you can render a triangle in any direction:

:root {
  --triangle-up: polygon(0% 100%, 50% 0%, 100% 100%);
  --triangle-right: polygon(0 0, 0 100%, 100% 50%);
  --triangle-down: polygon(100% 0, 0 0, 50% 100%);
  --triangle-left: polygon(100% 100%, 100% 0, 0 50%);
}

.triangle {
  width: 2rem;
  height: 2rem;
  background-color: blue;
}

.triangle-up {
  clip-path: var(--triangle-up);
}

.triangle-right {
  clip-path: var(--triangle-right);
}

.triangle-down {
  clip-path: var(--triangle-down);
}

.triangle-left {
  clip-path: var(--triangle-left);
}
<div class="triangle triangle-up"></div>
<div class="triangle triangle-right"></div>
<div class="triangle triangle-down"></div>
<div class="triangle triangle-left"></div>

Alternatively, you can use transform: rotate to adjust the triangle’s position:

:root {
  --triangle-shape: polygon(0% 100%, 50% 0%, 100% 100%);
}

.triangle {
  width: 2rem;
  height: 2rem;
  background-color: green;
  clip-path: var(--triangle-shape);
}

.triangle-rotate-up {
  transform: rotate(0deg);
}

.triangle-rotate-right {
  transform: rotate(90deg);
}

.triangle-rotate-down {
  transform: rotate(180deg);
}

.triangle-rotate-left {
  transform: rotate(-90deg);
}
<div class="triangle triangle-rotate-up"></div>
<div class="triangle triangle-rotate-right"></div>
<div class="triangle triangle-rotate-down"></div>
<div class="triangle triangle-rotate-left"></div>