Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active November 30, 2025 23:10
Show Gist options
  • Select an option

  • Save loilo/ed6f8031548b04f630d4affe743873a3 to your computer and use it in GitHub Desktop.

Select an option

Save loilo/ed6f8031548b04f630d4affe743873a3 to your computer and use it in GitHub Desktop.
Sass Responsive Utilities

Sass Responsive Utilities

Tip

These are some Sass mixins and functions for handling responsiveness which I'm using in virtually every project.

Configurataion

At the start of the responsive.scss you can find configuration variables for you to adjust.

Setup

Save the responsive.scss into your project and @use it at the beginning of the file where it's used:

@use 'path/to/responsive' as *;

Usage

Terminology

The following words are used throughout this documentation:

Term Meaning
breakpoint A named screen width defined in the configuration (e.g. sm, md, lg, etc.)
breakpoint range The range of screen widths starting from a breakpoint up to (but not including) the next breakpoint
pixel-like value A Sass number with either no unit at all or the px unit (e.g. 500 or 500px)
breakpoint-like value A pixel-like value or a breakpoint name string (e.g. 500, 500px, or 'md')

Breakpoints

Use the match/no-match mixin to apply styles conditionally based on a breakpoint-like value.

@include match(800) {
  // Screen is at least 800 pixels wide (unit is optional)
}

@include match('md') {
  // Screen matches at least the 'md' breakpoint
}

Pass two breakpoint-like values to match a range:

@include match('md', 'xl') {
  // Screen matches at least the 'md' breakpoint,
  // up until just below the 'xl' breakpoint
}

@include match('md', 'xl', $inclusive: true) {
  // Screen matches at least the 'md' breakpoint,
  // up until and including the 'xl' breakpoint range
}

The no-match mixin works exactly the same, just negated:

@include no-match('md') {
  // Styles for screens below the 'md' breakpoint
}

The match-exact mixin can be used to target exactly one breakpoint range:

@include match-exact('md') {
  // Styles for screens between 'md' and just below the next breakpoint
  // This is functionally equivalent to match('md', 'md', $inclusive: true) { ... }
}

Responsive Properties

The responsive mixin allows to set different values for different breakpoints in a single declaration.

body {
  @include responsive(font-size, (
    'xs': 1rem,
    'sm': 1.125rem,
    'md': 1.25rem,
    'lg': 1.5rem,
  ));
}

Just like in match(), keys can also be arbitrary CSS lengths (or bare numbers, interpreted as pixels):

body {
  @include responsive(font-size, (
    'xs': 1rem,
    500: 1.25rem,
    60rem: 1.5rem,
    1200px: 2rem,
  ));
}

Values can also be interpolated linearly between breakpoints. In that case, only breakpoint-like keys and pixel-like values are allowed:

body {
  @include responsive(font-size, (
    'xs': 16px,
    'sm': 18px,
    'md': 22px,
  ), $interpolate: true);
}

The c-responsive mixin works the same way, but uses container queries instead of media queries. This means that named breakpoints cannot be used as keys, only CSS lengths or bare numbers:

.columns {
  @include c-responsive(column-count, (
    400: 1,
    800: 2,
    1200: 3,
  ));
}

Interpolation also works with c-responsive(), with the same restrictions as in responsive().

Fluid Functions

The grow() function can be used to scale linearly between two pixel-like values:

body {
  // Scale the font size from 16px to 20px between the configured min and max screen widths
  font-size: grow(16, 20);
}

By default, the configured $min-screen-width/$max-screen-width values are used as the screen width range during which the value is interpolated. These can be overridden by passing breakpoint-like values to the $from and $to parameters:

body {
  // Scale the font size from 16px to 20px between 'sm' breakpoint and 1440px screen width
  font-size: grow(16, 20, $from: 'sm', $to: 1440);
}

The c-grow() function works the same way, but uses container widths instead of screen widths:

.container {
  // Scale the padding from 16px to 32px between the configured min and max container widths
  padding: c-grow(16, 32);

  // Scale the padding from 16px to 32px between 400px and 1200px container widths
  // As in c-responsive(), only pixel-like values are allowed for $from and $to
  padding: c-grow(16, 32, $from: 400, $to: 1200);
}

Utilities

The resolve-size() function can be used to get a CSS length from a value which might be a length or a breakpoint name:

$length-1: resolve-size('md');  // returns e.g. 1024px
$length-2: resolve-size(500);   // returns 500px
$length-3: resolve-size(60rem); // returns 60rem
@use 'sass:meta';
@use 'sass:math';
@use 'sass:map';
@use 'sass:list';
// Named breakpoints
$breakpoints: (
'xs': 0,
's': 540,
'sm': 768,
'md': 1024,
'lg': 1200,
'xl': 1400,
'2xl': 1600,
'3xl': 1900,
'4xl': 2500,
) !default;
// Bounds for the grow() function
$min-screen-width: 320 !default;
$max-screen-width: '4xl' !default;
// Bounds for the c-grow() function
$min-container-width: 320 !default;
$max-container-width: 2500 !default;
// --- End of user config ---
$breakpoint-names: map.keys($breakpoints);
/// Get the width number of a breakpoint
///
/// @param {String} $name
///
@function get-breakpoint-width($name) {
@if map.has-key($breakpoints, $name) {
@return map.get($breakpoints, $name);
} @else {
@error "No breakpoint named `#{$name}` found.";
}
}
/// Get the name of the next larger breakpoint
///
/// @param {String} $name
/// @return {String|null}
///
@function get-next-breakpoint-name($name) {
@if not map.has-key($breakpoints, $name) {
@error "No breakpoint named `#{$name}` found.";
}
$index: list.index($breakpoint-names, $name);
@if $index < list.length($breakpoint-names) {
@return list.nth($breakpoint-names, $index + 1);
} @else {
@return null;
}
}
/// Get the length of a CSS unit
///
/// @param {String} $unit
/// @return {Number}
///
@function get-unit-length($unit) {
// prettier-ignore
$units: ('cm': 1cm, 'mm': 1mm, 'q': 1q, 'in': 1in, 'pc': 1pc, 'pt': 1pt, 'px': 1px, 'em': 1em, 'ex': 1ex, 'cap': 1cap, 'ch': 1ch, 'ic': 1ic, 'lh': 1lh, 'rem': 1rem, 'rex': 1rex, 'rcap': 1rcap, 'rch': 1rch, 'ric': 1ric, 'rlh': 1rlh, 'vw': 1vw, 'vh': 1vh, 'vi': 1vi, 'vb': 1vb, 'vmin': 1vmin, 'vmax': 1vmax, 'svw': 1svw, 'svh': 1svh, 'svi': 1svi, 'svb': 1svb, 'svmin': 1svmin, 'svmax': 1svmax, 'lvw': 1lvw, 'lvh': 1lvh, 'lvi': 1lvi, 'lvb': 1lvb, 'lvmin': 1lvmin, 'lvmax': 1lvmax, 'dvw': 1dvw, 'dvh': 1dvh, 'dvi': 1dvi, 'dvb': 1dvb, 'dvmin': 1dvmin, 'dvmax': 1dvmax, 'cqw': 1cqw, 'cqh': 1cqh, 'cqi': 1cqi, 'cqb': 1cqb, 'cqmin': 1cqmin, 'cqmax': 1cqmax, '%': 1%, 'fr': 1fr, 'deg': 1deg, 'grad': 1grad, 'rad': 1rad, 'turn': 1turn, 's': 1s, 'ms': 1ms, 'hz': 1hz, 'khz': 1khz, 'dpi': 1dpi, 'dpcm': 1dpcm, 'dppx': 1dppx, 'x': 1x);
@if not map.has-key($units, $unit) {
@error 'Unsupported unit `#{$unit}`.';
}
@return map.get($units, $unit);
}
/// Strip unit from a number
/// @param {Number} $value
/// @return {Number}
///
@function strip-unit($value) {
@return math.div($value, $value * 0 + 1);
}
/// Enforce a unit on a number
///
/// Keeps input if it already has the correct unit, attached it on a unitless number
/// and errors if the input has a different unit.
///
/// @param {Number} $value
/// @param {String} $unit The unit to enforce, e.g. 'px', 'rem', 'em', 'vw', 'vh' or 'number' for unitless
/// @return {Number}
///
@function force-unit($value, $unit) {
@if $unit == 'number' {
@return strip-unit($value);
}
@if math.is-unitless($value) {
@return $value * get-unit-length($unit);
} @else {
@if math.unit($value) != $unit {
@error "Value `#{$value}` does not have the expected unit `#{$unit}`.";
}
@return $value;
}
}
/// Set a unit on a number if it does not have one
///
/// @param {Number} $value
/// @param {String} $unit The unit to enforce, e.g. 'px', 'rem', 'em', 'vw', 'vh' or 'number' for unitless
/// @return {Number}
///
@function with-unit($value, $unit) {
@if math.is-unitless($value) {
@return force-unit($value, $unit);
} @else {
@return $value;
}
}
/// Resolve a given size (CSS length, number or breakpoint name) to a Sass with unit
///
/// @param {String|Number} $size
/// @return {Number}
///
@function resolve-size($size) {
@if meta.type-of($size) == 'string' {
@if map.has-key($breakpoints, $size) {
$size: map.get($breakpoints, $size);
} @else {
@error "No breakpoint named `#{$size}` found.";
}
}
@return with-unit($size, 'px');
}
/// Wrap contents in a media query to match the provided breakpoint (name or length)
///
/// @param {String|Number} $lower
/// @param {String|Number} $upper
/// @param {Boolean} $invert
/// @param {Boolean} $inclusive
///
@mixin -match($lower, $upper: null, $invert: false, $inclusive: false) {
$lower-with-unit: resolve-size($lower);
@if $upper != null and $inclusive == true {
@if meta.type-of($upper) != 'string' {
@error "Inclusive upper bound only supports breakpoint names as string, got `#{$upper}`.";
}
$upper: get-next-breakpoint-name($upper);
}
$has-upper: $upper != null;
$lower-comment: '';
@if meta.type-of($lower) == 'string' {
$lower-comment: ' /* ' + $lower + ' */';
}
@if strip-unit($lower-with-unit) == 0 and not $has-upper {
@if not $invert {
@content;
} @else {
@warn "no-match() with lower bound of 0 can not match anything.";
}
} @else if $has-upper {
$invert-operator: '';
@if $invert {
$invert-operator: 'not ';
}
$upper-with-unit: '';
@if $has-upper {
$upper-with-unit: ' #{resolve-size($upper)}';
}
$upper-operator: '';
@if $has-upper {
$upper-operator: ' <';
}
$upper-comment: '';
@if $has-upper and meta.type-of($upper) == 'string' {
$upper-comment: ' /* ' + $upper + ' */';
}
$lower-operator: ' <= ';
@if strip-unit($lower-with-unit) == 0 {
$lower-operator: '';
$lower-with-unit: '';
$lower-comment: '';
}
@#{'media'} #{$invert-operator}(#{$lower-with-unit}#{$lower-comment}#{$lower-operator}width#{$upper-operator}#{$upper-with-unit}#{$upper-comment}) {
@content;
}
} @else if $invert {
@#{'media'} (width < #{$lower-with-unit}#{$lower-comment}) {
@content;
}
} @else {
@#{'media'} (width >= #{$lower-with-unit}#{$lower-comment}) {
@content;
}
}
}
/// Wrap contents in a media query to match the provided breakpoint (name or length)
///
/// @param {String|Number} $lower
/// @param {String|Number} $upper
/// @param {'none'|'lower'|'upper'|'both'} $inclusive
///
@mixin match($lower, $upper: null, $inclusive: 'lower') {
@include -match($lower, $upper, $inclusive: $inclusive) {
@content;
}
}
/// Wrap contents in a media query to match the provided breakpoint (name or length)
///
/// @param {String} $breakpoint
///
@mixin match-exact($breakpoint) {
@if meta.type-of($breakpoint) != 'string' {
@error "match-exact() only supports breakpoint names as string, got `#{$breakpoint}`.";
}
@include -match($breakpoint, $breakpoint, $inclusive: true) {
@content;
}
}
/// Wrap contents in a media query to match the provided breakpoint (name or length)
///
/// @param {String|Number} $lower
/// @param {String|Number} $upper
/// @param {'none'|'lower'|'upper'|'both'} $inclusive
///
@mixin no-match($lower, $upper: null, $inclusive: 'lower') {
@include -match($lower, $upper, $inclusive: $inclusive, $invert: true) {
@content;
}
}
/// Assign value to a property
/// If value is a map, keys are used as breakpoint names/lengths with according values
///
/// @param {String} $property
/// @param {*} $value
/// @param {Boolean} $interpolate
///
@mixin responsive($property, $value, $interpolate: false) {
@if meta.type-of($value) != 'map' {
#{$property}: with-unit($value, 'px');
} @else if not $interpolate {
@each $breakpoint-key, $breakpoint-value in $value {
@include match($breakpoint-key) {
#{$property}: with-unit($breakpoint-value, 'px');
}
}
} @else {
$map: $value;
$interpolated-breakpoint-names: map.keys($map);
$num-breakpoints: list.length($interpolated-breakpoint-names);
@if ($num-breakpoints < 2) {
@error "responsive() $map must receive at least two breakpoints";
}
$index: 1;
@each $breakpoint, $size in $map {
@if $index < $num-breakpoints {
$next-breakpoint: list.nth($interpolated-breakpoint-names, $index + 1);
$next-size: map.get($map, $next-breakpoint);
@include match($breakpoint) {
#{$property}: -grow(
$min-value: $size,
$max-value: $next-size,
$min-context-width: force-unit(resolve-size($breakpoint), 'px'),
$max-context-width: force-unit(resolve-size($next-breakpoint), 'px'),
$context-unit: 'vw'
);
}
}
$index: $index + 1;
}
}
}
/// Assign value to a property
/// If value is a map, keys are used as container query lengths with according values
///
/// @param {String} $property
/// @param {*} $value
/// @param {String} $container
/// @param {Boolean} $interpolate
///
@mixin c-responsive($property, $value, $container: '', $interpolate: false) {
@if meta.type-of($value) != 'map' {
#{$property}: with-unit($value, 'px');
} @else if not $interpolate {
@each $breakpoint-key, $breakpoint-value in $value {
@if meta.type-of($breakpoint-key) != 'number' {
@error "c-responsive() only supports numeric keys in the map, got `#{$breakpoint-key}`.";
}
$container-size: resolve-size($breakpoint-key);
@if strip-unit($container-size) == 0 {
#{$property}: with-unit($breakpoint-value, 'px');
} @else {
@container #{$container} (width >= #{$container-size}) {
#{$property}: with-unit($breakpoint-value, 'px');
}
}
}
} @else {
@if $container != '' {
@error "Using c-responsive() with $interpolate: true does not support custom $container names.";
}
$map: $value;
$interpolated-breakpoint-names: map.keys($map);
$num-breakpoints: list.length($interpolated-breakpoint-names);
@if ($num-breakpoints < 2) {
@error "c-responsive() $map must receive at least two breakpoints";
}
$index: 1;
@each $breakpoint, $size in $map {
@if meta.type-of($breakpoint) != 'number' {
@error "c-responsive() only supports numeric keys in the map, got `#{$breakpoint}`.";
}
$breakpoint-length: with-unit($breakpoint, 'px');
@if $index < $num-breakpoints {
$next-breakpoint: list.nth($interpolated-breakpoint-names, $index + 1);
$next-size: map.get($map, $next-breakpoint);
$value: -grow(
$min-value: $size,
$max-value: $next-size,
$min-context-width: $breakpoint,
$max-context-width: $next-breakpoint,
$context-unit: 'cqw',
);
@if strip-unit($breakpoint-length) == 0 {
#{$property}: #{$value};
} @else {
@container #{$container} (width >= #{$breakpoint-length}) {
#{$property}: #{$value};
}
}
}
$index: $index + 1;
}
}
}
/// Grow a length value between two bounds, scaling with the screen size
///
@function grow($min-value, $max-value, $from: $min-screen-width, $to: $max-screen-width, $rem: 16) {
$from-stripped: strip-unit(force-unit(resolve-size($from), 'px'));
$to-stripped: strip-unit(force-unit(resolve-size($to), 'px'));
$debug-comment-addition: '';
@if $from-stripped != strip-unit(force-unit(resolve-size($min-screen-width), 'px')) {
$debug-comment-addition: '#{$debug-comment-addition}, $from: #{$from}';
}
@if $to-stripped != strip-unit(force-unit(resolve-size($max-screen-width), 'px')) {
$debug-comment-addition: '#{$debug-comment-addition}, $to: #{$to}';
}
$debug-comment: #{'/* grow(#{$min-value}, #{$max-value}#{$debug-comment-addition}) */'};
@return #{$debug-comment} -grow(
$min-value,
$max-value,
$from-stripped,
$to-stripped,
$context-unit: 'vw',
$rem: $rem
);
}
/// Grow a length value between two bounds, scaling with the container size
///
@function c-grow(
$min-value,
$max-value,
$from: $min-container-width,
$to: $max-container-width,
$rem: 16
) {
$debug-comment-addition: '';
@if $from != $min-container-width {
$debug-comment-addition: '#{$debug-comment-addition}, $from: #{$from}';
}
@if $to != $max-container-width {
$debug-comment-addition: '#{$debug-comment-addition}, $to: #{$to}';
}
$debug-comment: #{'/* c-grow(#{$min-value}, #{$max-value}#{$debug-comment-addition})) */'};
@if meta.type-of($from) != 'number' or meta.type-of($to) != 'number' {
@error "c-grow() $from and $to parameters must be numbers.";
}
@return #{$debug-comment} -grow(
$min-value,
$max-value,
$from,
$to,
$context-unit: 'cqw',
$rem: $rem
);
}
/// Grow a length value between two bounds, scaling with the provided context unit
///
@function -grow(
$min-value,
$max-value,
$min-context-width,
$max-context-width,
$context-unit,
$rem: 16
) {
$min-value-stripped: strip-unit(force-unit($min-value, 'px'));
$max-value-stripped: strip-unit(force-unit($max-value, 'px'));
$min-context-width-stripped: strip-unit(force-unit($min-context-width, 'px'));
$max-context-width-stripped: strip-unit(force-unit($max-context-width, 'px'));
$min-value-rem: math.div($min-value-stripped, $rem);
$max-value-rem: math.div($max-value-stripped, $rem);
@if $min-value-stripped == $max-value-stripped {
@return $min-value-rem * 1rem;
}
$min-width: math.div($min-context-width-stripped, $rem);
$max-width: math.div($max-context-width-stripped, $rem);
$slope: math.div($max-value-rem - $min-value-rem, $max-width - $min-width);
$y-axis-intersection: ((-$min-width * $slope) + $min-value-rem) * 1rem;
$y-axis-part: '';
@if strip-unit($y-axis-intersection) != 0 {
$y-axis-part: '#{$y-axis-intersection} + ';
}
$clamp-min: #{math.min($min-value-rem, $max-value-rem)}rem;
$clamp-max: #{math.max($min-value-rem, $max-value-rem)}rem;
@return clamp(#{$clamp-min}, #{$y-axis-part}#{$slope * 100}#{$context-unit}, #{$clamp-max});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment