Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save arenagroove/6a07f7354c93045773c403ebb25c6e3d to your computer and use it in GitHub Desktop.

Select an option

Save arenagroove/6a07f7354c93045773c403ebb25c6e3d to your computer and use it in GitHub Desktop.
Sass mixins and utilities for generating modular type scales with responsive behavior. Supports static, fluid, interpolated, and manual step-to-step modes with clamp() output and CSS variables.

Modular Type Scale Sass Mixins

Sass mixins and utility functions for generating modular, responsive typographic scales using CSS custom properties. It includes four scalable strategies:

  1. Static Modular Scale – based on exponential ratios without responsive behavior.
  2. Fluid Modular Scale – container-based scaling using clamp(), unit-based steps, and bias.
  3. Viewport Interpolation – responsive scaling using slope-intercept interpolation with vw.
  4. Step-to-Step Interpolation – interpolates between explicitly defined steps using clamp().

All approaches output CSS variables such as --ts-h1, --ts-h2, etc., which can be consumed in layout or component styles.

Live Demo

Codepen Demo: https://codepen.io/luis-lessrain/pen/oggEVGr

Features

  • Modular ratio presets (e.g., perfect fourth, golden ratio)
  • Precision control with decimal rounding
  • Unit conversion between px and rem
  • clamp()-based interpolation utilities
  • Built for maintainability and design token generation

Contents

  • @function round($value, $places)
  • @function convert-to-unit($value, $target-unit)
  • @function generate-clamp-interpolation(...)
  • @mixin generate-static-scale(...)
  • @mixin generate-fluid-modular-scale(...)
  • @mixin generate-interpolated-scale(...)
  • @mixin generate-step-to-step-interpolated-scale(...)
@use "sass:math";
// -------------------------------------------------------------------
// Modular Type Scale Ratios
// -------------------------------------------------------------------
$scale-ratios: (minor-second: 1.067,
major-second: 1.125,
minor-third: 1.2,
major-third: 1.25,
perfect-fourth: 1.333,
augmented-fourth: 1.414,
perfect-fifth: 1.5,
golden-ratio: 1.618);
// -------------------------------------------------------------------
// Utility: Round to a fixed number of decimal places
// -------------------------------------------------------------------
@function round($value, $places: 4) {
$factor: math.pow(10, $places);
@return math.div(math.round($value * $factor), $factor);
}
// -------------------------------------------------------------------
// Utility: Convert between px and rem
// -------------------------------------------------------------------
@function convert-to-unit($value, $target-unit, $stringify: false) {
$from-unit: unit($value);
$from-lower: to-lower-case($from-unit);
$target-lower: to-lower-case($target-unit);
@if $target-lower=="unitless" {
@return math.div($value, 1#{$from-unit});
}
// Return raw numeric result; format only if $stringify is true
@if $from-lower==$target-lower {
@return if($stringify and $target-unit=="PX", unquote("#{math.div($value, 1px)}PX"), $value);
}
// rem → px
@if $from-lower=="rem" and $target-lower=="px" {
$px-value: math.div($value, 1rem) * 16px;
@return if($stringify and $target-unit=="PX", unquote("#{math.div($px-value, 1px)}PX"), $px-value);
}
// px → rem
@if $from-lower=="px" and $target-lower=="rem" {
@return math.div($value, 16px) * 1rem;
}
@error "Unsupported conversion: #{$from-unit} to #{$target-unit}";
}
// -------------------------------------------------------------------
// Utility: Generate viewport-based clamp()
// -------------------------------------------------------------------
@function generate-clamp-interpolation($min-font,
$max-font,
$min-screen: 320px,
$max-screen: 1200px,
$output-unit: px) {
// Use math.div only on numeric values
$slope: math.div(($max-font - $min-font), ($max-screen - $min-screen));
$intercept: $min-font - ($slope * $min-screen);
$vw-coeff: round($slope * 100, 4);
$base: round($intercept, 4);
// Optional formatting
@if $output-unit=="unitless" {
@return unquote("clamp(#{math.div($min-font, 1px)}, #{$vw-coeff}vw + #{math.div($base, 1px)}, #{math.div($max-font, 1px)})");
}
@if $output-unit=="PX" {
@return unquote("clamp(#{math.div($min-font, 1px)}PX, #{$vw-coeff}vw + #{math.div($base, 1px)}PX, #{math.div($max-font, 1px)}PX)");
}
@return unquote("clamp(#{$min-font}, #{$vw-coeff}vw + #{$base}, #{$max-font})");
}
// -------------------------------------------------------------------
// Mixin: Static Modular Scale
// -------------------------------------------------------------------
@mixin generate-static-scale($steps,
$base-size: 1rem,
$scale: perfect-fourth,
$output-unit: rem) {
$ratio: map-get($scale-ratios, $scale);
@if not $ratio {
@error "Unknown scale `#{$scale}`. Available: #{map-keys($scale-ratios)}";
}
@each $label,
$step in $steps {
$size: $base-size * math.pow($ratio, $step);
$converted-size: convert-to-unit(round($size), $output-unit, false); // math-safe
$formatted-size: convert-to-unit($converted-size, $output-unit, true); // stringify for output
#{"--ts-#{$label}"}: #{$formatted-size};
}
}
// -------------------------------------------------------------------
// Mixin: Fluid Modular Scale with clamp()
// -------------------------------------------------------------------
@mixin generate-fluid-modular-scale($steps,
$base-size: 1rem,
$scale: perfect-fourth,
$unit-step: 1cqw,
$bias: 0.5rem,
$min-mult: 0.9,
$max-mult: 1.1,
$interpolation: false,
$output-unit: rem) {
$ratio: map-get($scale-ratios, $scale);
@if not $ratio {
@error "Unknown scale `#{$scale}`. Available: #{map-keys($scale-ratios)}";
}
@each $label,
$step in $steps {
$size: if($interpolation,
$base-size * (math.pow($ratio, math.floor($step)) * (1 - ($step - math.floor($step))) + math.pow($ratio, math.ceil($step)) * ($step - math.floor($step))),
$base-size * math.pow($ratio, $step));
// Convert with raw math-safe values
$min: convert-to-unit(round($size * $min-mult), $output-unit, false);
$max: convert-to-unit(round($size * $max-mult), $output-unit, false);
$bias-conv: convert-to-unit($bias, $output-unit, false);
$fluid: $step * $unit-step;
@if $min==$max or $fluid==0 or $bias-conv==0 {
$final-val: convert-to-unit($min, $output-unit, true);
#{"--ts-#{$label}"}: #{$final-val};
}
@else {
$min-str: convert-to-unit($min, $output-unit, true);
$max-str: convert-to-unit($max, $output-unit, true);
$bias-str: convert-to-unit($bias-conv, $output-unit, true);
#{"--ts-#{$label}"}: unquote("clamp(#{$min-str}, calc(#{$fluid} + #{$bias-str}), #{$max-str})");
}
}
}
// -------------------------------------------------------------------
// Mixin: Tool-style Interpolated Clamp Scale
// -------------------------------------------------------------------
@mixin generate-interpolated-scale($steps,
$scale: null,
$base-size: 1rem,
$min-mult: 0.9,
$max-mult: 1.1,
$output-unit: rem,
$min-screen: 320px,
$max-screen: 1200px) {
@each $label,
$step in $steps {
@if unitless($step) {
@if $scale==null {
@error "Interpolation mode requires $scale for unitless step `#{$label}`.";
}
$ratio: map-get($scale-ratios, $scale);
@if not $ratio {
@error "Unknown scale `#{$scale}`. Available: #{map-keys($scale-ratios)}";
}
$size: $base-size * math.pow($ratio, $step);
$min: convert-to-unit(round($size * $min-mult), $output-unit, false);
$max: convert-to-unit(round($size * $max-mult), $output-unit, false);
#{"--ts-#{$label}"}: #{generate-clamp-interpolation($min, $max, $min-screen, $max-screen, $output-unit)};
}
@else {
$min: convert-to-unit($step, $output-unit, false);
$max: convert-to-unit($step * $max-mult, $output-unit, false);
#{"--ts-#{$label}"}: #{generate-clamp-interpolation($min, $max, $min-screen, $max-screen, $output-unit)};
}
}
}
// -------------------------------------------------------------------
// Mixin: Step-to-Step Interpolated Scale
// -------------------------------------------------------------------
// Each label interpolates between the previous and current step
// Must provide exact size values (e.g. in px or rem) per label
// -------------------------------------------------------------------
@mixin generate-step-to-step-interpolated-scale($steps,
$output-unit: rem,
$min-screen: 320px,
$max-screen: 1200px) {
$prev-size: null;
@each $label,
$value in $steps {
// Use raw mode — don't stringify here
$curr-size: convert-to-unit($value, $output-unit, false);
@if $prev-size==null {
// Only stringify when assigning to CSS custom prop
$formatted: convert-to-unit($curr-size, $output-unit, true);
#{"--ts-#{$label}"}: #{$formatted};
}
@else {
$min: $prev-size;
$max: $curr-size;
#{"--ts-#{$label}"}: #{generate-clamp-interpolation($min, $max, $min-screen, $max-screen, $output-unit)};
}
$prev-size: $curr-size;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment