Before the summer, I had the pleasure to read this article that talked about splitting your css into multiple files to optimize file size. At the time, I thought: well, this is nice, but that is going to be cumbersome to implement.
Recently, I made a small one-pager and I decided to give it a try. On this website, I'm using bootstrap 5 and I'm going to show you how I manage to get it working.
What are we working towards?
The end goal is to have something like this:
<link href="/css/default.min.css" rel="stylesheet" />
<link href="/css/mobile.min.css" media="screen and (max-width: 767.98px)" rel="stylesheet" />
<link href="/css/tablet.min.css" media="screen and (min-width: 768px) and (max-width: 991.98px)" rel="stylesheet" />
<link href="/css/desktop.min.css" media="screen and (min-width: 992px)" rel="stylesheet" />
<link href="/css/print.min.css" media="print" rel="stylesheet" />
At the same time, in my source file, I don't want to split my logic between files. This means, that the following scss code:
// This should go to default.min.css
.col-separator {
border-right: 1px solid $primary;
}
// This should go to mobile.min.css
@include media-breakpoint-down(md) {
.col-separator {
border-right: 0;
}
}
Should get split properly between multiple files. While scss is really great at combining files, it does not provide much out of the box to split files.
Getting your structure ready
First things first, let's get our files in a proper structure. In my workflow, where I'm using plain dart sass cli tooling, I need to find a way to have my files sent to the /public/css folder. I do it like this:
"scripts": {
"build": "esbuild --bundle --minify --format=esm --sourcemap src/index.js --outfile=public/js/index.min.js",
"start": "npm run build -- --servedir=./public",
"watch": "npm run build -- --watch",
"compile-css": "sass scss/dist:public/css --load-path=node_modules/",
"compile-min-css": "sass scss/min:public/css --style compressed --load-path=node_modules/",
"watch-css": "npm run compile-css -- --watch --poll",
"watch-min-css": "npm run compile-min-css -- --watch --poll",
"prefix-css": "postcss public/css/*.min.css --replace --use autoprefixer",
"build-all": "npm run build && npm run compile-css && npm run compile-min-css && npm run prefix-css"
}
I create two folders: one scss/dist
and one scss/min
. Both of them get their own compile command with dedicated settings. If you only want minified files, it gets even simpler.
In these folders, I create the following files:
default(.min).scss : this is the general stylesheet that get non specific styles
desktop(.min).scss : for desktop (> 992px)
mobile(.min).scss: for mobile (< 768px)
tablet(.min).scss: for tablet (between 768 and 992px). Not sure this one is actually worth it, feel free to remove it and put these styles in default instead.
The content of these files is the same:
$css-sheet: "mobile"; // change according to current file
@import "../bootstrap/bootstrap-common";
@import "../index";
The only difference is this mysterious css-sheet
variable.
Let's get the print out of the way
But before diving into the media queries, let's go the print styles out of the way. Bootstrap 5 removed their print styles, so if you want to restore them, you need to create a distinct file. You can find its content here, but the general idea is like this:
Creating some nice default print styles
Include print utils through a reduced utilities api
Remove the print utils from the regular utilities api to prevent their inclusion in default.css
Ok, simple enough. Let's have a look at the media queries now.
Sending media queries to distinct files
The main issue with my first snippet is that there is no way to know what should belong to the "default" stylesheet. Let's solve this:
@include media-default {
.col-separator {
border-right: 1px solid $primary;
}
}
@include media-breakpoint-down(md) {
.col-separator {
border-right: 0;
}
}
Notice the updated media-default
mixin. This is actually kind of nice because it allows you to treat "base" scss code into its own block. The issue with this is that my approach doesn't work for code that is not organized to work with it, so base bootstrap code will have to be sent to default.scss.
Remember the little css-sheet
variable? This variable will help us to track which file we are currently processing and include (or not) the content based on it. This require overriding the default media queries mixins with the following code. Again, feel free to read to code (warning! while it seems to be working all right, there might be edge cases that I've missed), but the basic idea is this:
We know that some media queries are only relevant to a specific breakpoint and don't need to be included for some viewports (eg: include media-breakpoint-down(md) should only be included in mobile.css).
Knowing that, it's easy enough to process the media query mixins according to the current sheet being processed. Based on that, we ignore or include the content. We can even get rid of the top level media query if it matches the stylesheet criteria.
Our new mixin
media-default
allows us to flag content that should be included in the default.css and ignored on viewport specific sheets.
And that's it! As mentioned, this approach doesn't help us with base bootstrap code, but any custom code will be split properly into its own file.
At the end of the day, for my project, it proved not so effective because most of the code still belongs to default.css and due to bootstrap size, css splitting by viewport does not bring many benefits. But I can imagine that a framework engineered for these viewports or for large websites, this approach could provide a real size reduction.