Responsive Breakpoint Mixins
Every responsive site repeats the same few media queries: "from tablet width up", "below phone width", "only on tablets". Typing @media (width >= 48rem) in fifty places means fifty places to change when the design team moves the tablet breakpoint. A small set of breakpoint mixins gives those widths names and keeps them in one map.
In this lesson you will build that system step by step: a breakpoint map, a lookup function with a helpful error, up, down and between mixins, and a mixin for container queries. Frameworks such as Bootstrap ship mixins built on the same idea.
Step 1: one map of named widths
Choose breakpoints from your content and design, not from particular phone models. Store them in a map. Using rem means the breakpoints grow if a user increases their browser's default font size, which keeps the layout readable:
@use "sass:map";
$breakpoints: (
sm: 36rem, // 576px at the default 16px
md: 48rem, // 768px
lg: 64rem, // 1024px
xl: 80rem, // 1280px
);In a real project this map lives in a settings partial such as abstracts/_breakpoints.scss and is marked !default, so it can be configured with @use ... with.
Step 2: a lookup function that fails loudly
map.get() returns null for an unknown name, which would produce a broken media query. A small function turns that into a clear error (see @debug, @warn & @error):
@function bp($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint `#{$name}`. Use: #{map.keys($breakpoints)}.";
}
@return map.get($breakpoints, $name);
}Step 3: up, down and between
Modern CSS has range syntax for media queries, (width >= 48rem), supported in all current browsers. It reads naturally and avoids the old off-by-one problems. Each mixin wraps @content in the right query:
@use "sass:map";
$breakpoints: (sm: 36rem, md: 48rem, lg: 64rem, xl: 80rem);
@function bp($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint `#{$name}`. Use: #{map.keys($breakpoints)}.";
}
@return map.get($breakpoints, $name);
}
@mixin up($name) {
@media (width >= bp($name)) { @content; }
}
@mixin down($name) {
@media (width < bp($name)) { @content; }
}
@mixin between($from, $to) {
@media (bp($from) <= width < bp($to)) { @content; }
}
.product-grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
@include up(sm) { grid-template-columns: repeat(2, 1fr); }
@include up(lg) { grid-template-columns: repeat(4, 1fr); }
}
.promo-banner {
@include down(sm) { display: none; }
}
.side-note {
@include between(md, lg) { font-size: 0.875rem; }
}.product-grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (width >= 36rem) {
.product-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width >= 64rem) {
.product-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (width < 36rem) {
.promo-banner {
display: none;
}
}
@media (48rem <= width < 64rem) {
.side-note {
font-size: 0.875rem;
}
}This is the mobile-first approach: the base styles are for small screens, and up() adds styles as the screen grows. Use down() and between() for the few cases that only apply to a range.
Here is the compiled CSS on a page. Resize the preview to see the grid change from one column to two to four, and the promo banner disappear below 36rem:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Breakpoint mixins in action</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; }
.item { background: #fce7f3; border-radius: 8px; padding: 1rem; }
.promo-banner { background: #cc6699; color: white; padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; }
/* compiled from the SCSS above */
.product-grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (width >= 36rem) {
.product-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (width >= 64rem) {
.product-grid { grid-template-columns: repeat(4, 1fr); }
}
@media (width < 36rem) {
.promo-banner { display: none; }
}
</style>
</head>
<body>
<p class="promo-banner">Diwali sale in Jaipur: 20% off handloom sarees</p>
<div class="product-grid">
<div class="item">Bandhani saree, ₹2,499</div>
<div class="item">Block-print kurta, ₹899</div>
<div class="item">Leheriya dupatta, ₹649</div>
<div class="item">Mojari shoes, ₹1,199</div>
</div>
</body>
</html>Step 4: container queries
Media queries look at the viewport. Container queries look at the size of a parent element, so a card can change layout depending on the column it sits in, not on the screen. They work in all current browsers. The parent needs container-type: inline-size; a mixin keeps the query short:
@mixin container($min, $name: null) {
@container #{$name} (width >= #{$min}) {
@content;
}
}
.sidebar {
container: sidebar / inline-size;
}
.profile-card {
display: grid;
gap: 0.5rem;
@include container(24rem) {
grid-template-columns: 6rem 1fr;
}
@include container(40rem, sidebar) {
gap: 1.5rem;
}
}.sidebar {
container: sidebar/inline-size;
}
.profile-card {
display: grid;
gap: 0.5rem;
}
@container (width >= 24rem) {
.profile-card {
grid-template-columns: 6rem 1fr;
}
}
@container sidebar (width >= 40rem) {
.profile-card {
gap: 1.5rem;
}
}Sass prints the container shorthand as sidebar/inline-size, without spaces around the slash, which is still valid CSS. When $name is null, the interpolation outputs nothing and the query applies to the nearest container. With a name, it targets that named container. See Container Queries for the CSS side.
Common mistakes
- Too many breakpoints. Four or five named sizes are enough. Add a one-off query only when content truly breaks.
- Breakpoints in
pxwhile text is inrem. If users enlarge text,pxbreakpoints switch too late. Useremfor both. - Desktop-first everywhere. Writing large-screen styles first and undoing them with
down()produces more CSS. Start small and useup(). - Typos in names. Without the
bp()check,up(medium)silently produces a broken query. Keep the@error.
Key points
- Keep breakpoints in one map with readable names, in
rem, in a settings partial. - A
bp()function with@errorturns unknown names into clear messages. up(),down()andbetween()mixins wrap@contentin range-syntax media queries such as(width >= 48rem).- Range syntax avoids the old
max-width: 767.98pxtrick because<and>=never overlap. - A container query mixin works the same way for component-level responsiveness.
Exercise
A Kochi tourism site wants a different breakpoint map: phone: 30rem, tablet: 50rem, desktop: 75rem. Write the map, the bp() function and an up() mixin. Use them on .tour-list: one column by default, two from tablet, three from desktop. Add .tour-filters that is position: sticky; top: 0; only from desktop up. Check what happens if you call up(laptop).
Show solution
@use "sass:map";
$breakpoints: (phone: 30rem, tablet: 50rem, desktop: 75rem);
@function bp($name) {
@if not map.has-key($breakpoints, $name) {
@error "Unknown breakpoint `#{$name}`. Use: #{map.keys($breakpoints)}.";
}
@return map.get($breakpoints, $name);
}
@mixin up($name) {
@media (width >= bp($name)) { @content; }
}
.tour-list {
display: grid;
grid-template-columns: 1fr;
@include up(tablet) { grid-template-columns: repeat(2, 1fr); }
@include up(desktop) { grid-template-columns: repeat(3, 1fr); }
}
.tour-filters {
@include up(desktop) {
position: sticky;
top: 0;
}
}up(laptop) stops the build with "Unknown breakpoint laptop. Use: phone, tablet, desktop." instead of writing a broken media query.
Quick Quiz
1. Why store breakpoints in a map?
One map is the single source of truth; mixins look widths up by name.
2. What does @include up(md) produce with md: 48rem?
The up() mixin wraps its content in a min-width range query.
3. Why did older mixins write max-width: 767.98px?
min-width and max-width both include the boundary; range syntax < excludes it.
4. What does a container query respond to?
Container queries let a component adapt to the space it is placed in.
Learn Sass with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
