Responsive Mixins and Generating Utility Classes: a Breakpoint Map, @content(args), a Container Query Mixin and Utilities with @each/@for
Write up, down, between and only mixins that read breakpoints from one map with range syntax; this ends the overlap of max-width and min-width right at the boundary, and the same mixins produce container queries through $container. We add a generator that produces md:-prefixed utilities with @content(args) and a button that looks at the card's width to Bookstore, and measure that making everything responsive multiplies the rule count by 3.6.
Version note: The examples in this post were tested with Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; Sass 1.104.1 inside the Angular CLI) and the Chromium build of Playwright 1.65.
Guess First
In the old catalog styles of Bookstore, the online bookstore used as the shared example across this site's series, breakpoints have been written with the same habit for years: max-width for "below md", min-width for "md and up".
// the old way: max-width for "below md", min-width for "md and up"
.book-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
@media (min-width: 48rem) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 48rem) {
grid-template-columns: 1fr;
}
}
At the default font size, 48rem is 768 pixels. When the browser window is exactly 768px wide, how many columns does the catalog have? The design document says "three columns from md up".
In the third post of the series we started with a single from() mixin and generated utility classes from a map with @each. In the previous two posts we went through the built-in modules. In this post we combine the two and build a full API: up, down, between and only mixins that read from a map, @content(args) to pass values into the content block, container queries with the same mixins, and utility classes with responsive variants. At the end we measure the size of the CSS generated in Bookstore's Angular client (bookstore-web) and cut the combinations nobody needs.
The max-width/min-width Overlap and Range Syntax
I asked Chromium for the column count at three widths:
node scripts/10-guess.mjs
767px columns=1
768px columns=1
769px columns=3
At exactly 768px both media queries are true: min-width: 48rem means "48rem and up", max-width: 48rem means "48rem and below". Both apply and the one later in the source wins; at a width that should be "md and up" the page shows a single column. If the order of the rules were swapped, the bug would still be there, just going the other way. That is why older frameworks wrote "below md" with values like max-width: 47.98px.
Media query range syntax removes the problem: (width < 48rem) and (width >= 48rem) never overlap at any width. The syntax itself is covered in the responsive design post of the CSS3 series; Sass's job is to make sure nobody writes these queries by hand.
A Full API That Reads the Map: up, down, between, only
I rewrote Bookstore's breakpoint file from scratch. All mixins read the same map and build the query in a single private mixin:
@use "sass:list";
@use "sass:map";
@use "sass:meta";
$breakpoints: (
"sm": 36rem,
"md": 48rem,
"lg": 64rem,
"xl": 80rem,
) !default;
// a breakpoint name from the map, or a size given directly (for containers)
@function -size($name) {
@if meta.type-of($name) == number {
@return $name;
}
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint '#{$name}'. Expected one of: #{map.keys($breakpoints)}";
}
@return map.get($breakpoints, $name);
}
// the next breakpoint's name, or null after the last one
@function -next($name) {
$names: map.keys($breakpoints);
$i: list.index($names, $name);
@if $i == list.length($names) {
@return null;
}
@return list.nth($names, $i + 1);
}
// a media query by default; with $container, a query on that named container
@mixin -query($condition, $container) {
@if $container {
@container #{$container} #{$condition} {
@content;
}
} @else {
@media #{$condition} {
@content;
}
}
}
@mixin up($name, $container: null) {
@include -query("(width >= #{-size($name)})", $container) {
@content;
}
}
@mixin down($name, $container: null) {
@include -query("(width < #{-size($name)})", $container) {
@content;
}
}
@mixin between($min, $max, $container: null) {
@if -size($min) >= -size($max) {
@error "between(): '#{$min}' must be smaller than '#{$max}'";
}
@include -query("(#{-size($min)} <= width < #{-size($max)})", $container) {
@content;
}
}
@mixin only($name, $container: null) {
$next: -next($name);
@if $next {
@include between($name, $next, $container) {
@content;
}
} @else {
@include up($name, $container) {
@content;
}
}
}
There are a few design decisions here. down() is exclusive (<) and up() is inclusive (>=); called with the same breakpoint name, they leave neither a gap nor an overlap. only() finds the next breakpoint by order in the map, which is why the map's order matters and must go from small to large. For the last breakpoint, only("xl") becomes an up() without an upper limit. @content is passed on from one mixin to the next unchanged; the content of up() travels through two mixins and is written inside -query().
@use "breakpoints" as bp;
.book-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
@include bp.down("md") {
grid-template-columns: 1fr;
}
@include bp.up("md") {
grid-template-columns: repeat(3, 1fr);
}
@include bp.only("lg") {
gap: 24px;
}
@include bp.between("sm", "lg") {
padding-inline: 16px;
}
@include bp.only("xl") {
grid-template-columns: repeat(5, 1fr);
}
}
@media (width < 48rem) {
.book-list {
grid-template-columns: 1fr;
}
}
@media (width >= 48rem) {
.book-list {
grid-template-columns: repeat(3, 1fr);
}
}
@media (64rem <= width < 80rem) {
.book-list {
gap: 24px;
}
}
@media (36rem <= width < 64rem) {
.book-list {
padding-inline: 16px;
}
}
@media (width >= 80rem) {
.book-list {
grid-template-columns: repeat(5, 1fr);
}
}
With this file the same Playwright script printed 767px columns=1, 768px columns=3, 769px columns=3. A range given in the wrong order is caught at compile time too:
Error: "between(): 'lg' must be smaller than 'md'"
╷
4 │ @include bp.between("lg", "md") {
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
╵
10/between-error.scss 4:5 root stylesheet
@content(args) and using: Passing Values into the Content Block
When generating responsive utilities, it is not enough for the mixin to build the media query; the code inside needs to know which breakpoint it is in so it can put the md: prefix on the class name. @content can take arguments, and the caller receives them with using:
// runs the content block once per breakpoint and passes its name and class prefix
@mixin each-breakpoint {
@each $name, $size in $breakpoints {
@include up($name) {
@content($name, "#{$name}\\:");
}
}
}
@use "breakpoints" as bp;
$columns: (2, 4);
@include bp.each-breakpoint using ($name, $prefix) {
@each $n in $columns {
.#{$prefix}cols-#{$n} {
grid-template-columns: repeat($n, minmax(0, 1fr));
}
}
}
@media (width >= 36rem) {
.sm\:cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sm\:cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (width >= 48rem) {
.md\:cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
...
The double backslash in the string "#{$name}\\:" is a single backslash inside a Sass string; the output becomes .md\:cols-2. The escape is required because : starts a pseudo-class in a selector; it is the same thing we did for .w-1\/3 in the previous post. In HTML the class name has no escape: class="md:cols-2". The parameters in using behave like mixin parameters; they can have default values too.
You can ask sass:meta whether a mixin accepts a content block. This is useful in libraries that take a mixin as a value and pass it to another mixin:
.check {
up: meta.accepts-content(meta.get-mixin("up", "bp"));
visually-hidden: meta.accepts-content(meta.get-mixin("visually-hidden"));
}
.check {
up: true;
visually-hidden: false;
}
If you pass a block to a mixin without @content, compilation stops: Error: Mixin doesn't accept a content block. The message shows both the mixin's declaration and where it was called. We will use get-mixin() and meta.apply() to choose mixins when we write our own library.
The Same Mixin, Falling Back to a Container Query
Bookstore's book card is used in the catalog, in the cart and in the narrow side panel of the institutional portal. The "Add to cart" button should be full width on a narrow card and only as wide as its content on a wide one. That decision depends on the card's own width, not the viewport; in other words, a container query (covered in the container query post of the CSS3 series). I did not change the API: up(), down(), between() and only() all take a $container parameter and, when it is given, write the same condition with @container. Container sizes do not have to match the viewport breakpoints, which is why -size() accepts a number as well:
.book-card__add {
width: 100%;
// the card's own width decides, not the viewport
@include bp.up(20rem, $container: book-card) {
width: auto;
}
}
.book-card__add {
width: 100%;
}
@container book-card (width >= 20rem) {
.book-card__add {
width: auto;
}
}
The container itself is defined on the component's host element: :host { display: block; container: book-card / inline-size; }. In Angular, :host is the component's own element (app-book-card); custom elements are inline by default, so display: block is needed.
Generating Utility Classes and Measuring Their Size
The spacing utilities in the third post were generated with two nested @each loops, and every new utility family meant a new loop. Instead I wrote a generator that defines each utility as a map entry; the ones that need responsive variants are marked with responsive: true:
$spacers: (0: 0, 1: 4px, 2: 8px, 3: 16px, 4: 24px, 5: 32px);
$utilities: (
"m": (property: margin, values: $spacers),
"mt": (property: margin-block-start, values: $spacers),
"mb": (property: margin-block-end, values: $spacers),
"mx": (property: margin-inline, values: $spacers),
"p": (property: padding, values: $spacers),
"px": (property: padding-inline, values: $spacers),
"py": (property: padding-block, values: $spacers),
"gap": (property: gap, values: $spacers, responsive: true),
"d": (property: display, values: (none: none, block: block, flex: flex, grid: grid), responsive: true),
"text": (property: color, values: ("muted": var(--color-muted), "brand": var(--color-brand))),
);
@use "sass:list";
@use "sass:map";
@use "breakpoints" as bp;
// one rule per value; $prefix is "" or an escaped "md\:"
@mixin -rules($utilities, $prefix, $responsive-only) {
@each $name, $utility in $utilities {
@if not $responsive-only or map.get($utility, responsive) {
@each $key, $value in map.get($utility, values) {
.#{$prefix}#{$name}-#{$key} {
#{map.get($utility, property)}: $value;
}
}
}
}
}
// base classes for every utility, responsive variants only for those marked
// responsive and only for the breakpoints listed in $variants
@mixin generate($utilities, $variants) {
@include -rules($utilities, "", false);
@include bp.each-breakpoint using ($name, $prefix) {
@if list.index($variants, $name) {
@include -rules($utilities, $prefix, true);
}
}
}
The property name is generated with interpolation too (#{map.get($utility, property)}: $value). A new utility family is now one line in the map. The real question is how much of it you generate. I produced three outputs from the same data and measured their compressed size: md and lg variants only for gap and display (what Bookstore needs), all four breakpoints for every utility, and a 35-step spacing scale similar to Tailwind's, generated for 14 properties at four breakpoints:
node scripts/10-sizes.mjs
utilities-trimmed 74 rules 1712 bytes, gzip 477 bytes
utilities-everything 270 rules 7110 bytes, gzip 1162 bytes
utilities-large-scale 2450 rules 72231 bytes, gzip 10240 bytes
The "everything is responsive" decision produces 3.6 times as many rules from the same data. The large-scale version is 72 KB; add a color palette and state variants like hover: and the size goes into the hundreds of KB. Every byte of this CSS is render-blocking: the page is not painted until the global style sheet is downloaded and parsed. And Sass cannot know which classes are actually used; that information lives in the templates.
In Bookstore: a Breakpoint Set and a Small Utility Layer
In bookstore-web, abstracts/_breakpoints.scss was replaced with the API above; the generator mixin went into abstracts/_utilities.scss under the name generate-utilities(). Both only define mixins and functions; abstracts still produce no CSS. The from() calls in components became up(); from() stays as a mixin that forwards to up() for older code. The utility data lives in the utilities folder we set up in the sixth post and takes its values from the space() scale of the previous post:
// spacing utilities take their values from the space() scale: .m-0 … .m-5
$spacers: ();
@for $step from 0 through list.length($space-scale) - 1 {
$spacers: map.set($spacers, $step, space($step));
}
$utilities: (
"m": (property: margin, values: $spacers),
"mt": (property: margin-block-start, values: $spacers),
"mb": (property: margin-block-end, values: $spacers),
"mx": (property: margin-inline, values: $spacers),
"my": (property: margin-block, values: $spacers),
"gap": (property: gap, values: $spacers, responsive: true),
"d": (property: display, values: (none: none, block: block, flex: flex, grid: grid), responsive: true),
"text": (property: color, values: ("muted": var(--color-muted), "brand": var(--color-brand))),
);
// responsive variants (.md\:d-none) only for display and gap, only at md and lg
@include generate-utilities($utilities, ("md", "lg"));
The entry file loads this file inside @layer utilities, so all generated classes are in the strongest layer from the sixth post. Because the responsive variants come after the base classes in the same layer, class="d-none md:d-block" works as expected: at equal specificity, source order wins. I added a tagline to the header with these two classes, visible only on wide screens, plus the container-query button on the book card, and measured at three widths:
node scripts/10-web-responsive.mjs
viewport 400px card 352px button 122px tagline none
viewport 700px card 318px button 282px tagline none
viewport 1280px card 288px button 252px tagline block
The difference a container query makes is in the first two lines: on a 400px phone the card is 352px wide in a single column, larger than 20rem, so the button is as wide as its content. On a 700px tablet there are two columns, the card narrows to 318px and the button goes back to full width. A media query looking at the viewport could not tell these two cases apart. The global style sheet went from 11.71 kB to 12.56 kB; the utilities went from 25 rules to 62. npm run check:sass2 is still clean.
In Production
Track the size of the utility layer in CI. Adding a value to the map or a breakpoint to $variants adds dozens of rules without anyone noticing. Comparing the output of a script like 10-sizes.mjs against a threshold, or setting a limit for the global style sheet in Angular's budgets, makes this visible.
Keep the breakpoint map in one place. Apart from container query sizes, no component should write a media query by hand; otherwise a change in the map reaches some rules and not others. grep -rn "@media" src/app should return nothing.
Check browser support for range syntax. Bookstore's target browsers support it; if you need to support a very old browser, the change happens in one place, the -query() mixin. The rest of the API stays the same.
Common Pitfalls
Generating every value for every breakpoint. In the measurement above that decision multiplied the rule count by 3.6; in a large system it means 72 KB of CSS, most of it unused. Generate the base classes first and turn on responsive variants one by one as the need arises.
Trying to rewrite Tailwind in Sass. As a utility generator grows, requests for hover:, focus:, color opacity and arbitrary values arrive. Tailwind solves all of these by scanning the templates and generating only the classes in use; Sass does not see the templates and has to generate everything up front. If utilities carry most of the styling in a project, the right tool is Tailwind (in the Tailwind CSS series); a utility layer written in Sass should stay a small helper next to semantic classes, as in Bookstore.
Mixing max-width and min-width with the same value. The experiment at the start of the post showed two rules applying at once right at the boundary. Writing down() with range syntax and as exclusive removes this class of bug completely.
A prefix without the escape. If you write @content($name, "#{$name}:"), Sass raises no error and the output contains .md:d-block. The browser reads that not as the class md:d-block but as the class .md plus an unknown pseudo-class called :d-block, and drops the rule because the selector is invalid. 10-no-escape.mjs printed 0 for the number of rules left inside the media query in Chromium.
A Small Experiment
★☆☆ In 10/api.scss, show two catalog columns at tablet width with bp.only("md"). Read the media query in the output and verify the boundaries by adding widths of 1023, 1024 and 1025 pixels to 10-guess.mjs.
★★☆ Add a $states parameter to the generator: given ("hover"), it should produce .hover\:text-brand:hover rules for the marked utilities. Mark Bookstore's text utility and measure with 10-sizes.mjs how much the rule count grows.
★★★ In bookstore-web, write a Node script that scans all .html templates under src/app and lists the utility classes in use. It should write the list to a _used.scss file as a Sass list, and the generator should generate only the classes in that list. Measure the global style sheet before and after, and discuss at which point this approach turns into rewriting what Tailwind does.
In the next post we turn the functions and mixins collected in these posts into a library: argument validation with @debug, @warn and @error, sass:meta and sass:selector, a small internal package called @bookstore/styles, and Sass tests with True.