Skip to content

Instantly share code, notes, and snippets.

@arenagroove
Last active October 6, 2025 14:50
Show Gist options
  • Select an option

  • Save arenagroove/3e7d132ec593c55a0b3b383fb01376e6 to your computer and use it in GitHub Desktop.

Select an option

Save arenagroove/3e7d132ec593c55a0b3b383fb01376e6 to your computer and use it in GitHub Desktop.
Dart Sass–ready set of functions and mixins for static and fluid typography scales

Type Scale Utilities (Sass) — V2

Dart Sass–ready set of functions and mixins for static and fluid typography scales, using the Sass module system. Includes container-based fluid tokens with cqw and a viewport-based clamp() fallback.


Contents


Conventions

  • Module imports follow the project norm:
    • @use "abstracts/functions" as *; in mixins
    • @use "abstracts/functions/number" as num;
    • @use "abstracts/functions/units" as unit;
  • No global built-ins. Always use namespaced modules:
    • map.get, map.keys, math.unit, math.is-unitless, string.unquote.

Functions

abstracts/functions/_number.scss

// Number Functions
@use "sass:math";

/// Round a number to a specific number of decimal places.
/// Defaults to 2 decimals for general use.
@function round-to($value, $decimals: 2) {
  $factor: math.pow(10, $decimals);
  @return math.div(math.round($value * $factor), $factor);
}

/// Optional legacy alias for code that expects `round($v, $places)`.
@function round($value, $places: 2) {
  @return round-to($value, $places);
}

abstracts/functions/_units.scss

// Unit Conversion
@use "sass:math";
@use "sass:string";
@use "sass:meta";

$root-font-size: 16px !default; // base for px↔rem

// Returns "unitless" | "<unit>" for numbers; null for non-numbers
@function _unit-or-null($value) {
  @if meta.type-of($value) != 'number' { @return null; }
  @return if(math.is-unitless($value), "unitless", math.unit($value));
}

/// Convert between px, rem, unitless, and stringified "PX"
/// $target-unit: px | rem | unitless | "PX"
/// When $stringify is true and $target-unit is "PX", returns e.g. 12PX.
@function convert-to-unit($value, $target-unit, $stringify: false) {
  $from: _unit-or-null($value);

  // Non-number: pass through unless unitless requested
  @if $from == null {
    @if string.to-lower-case($target-unit) == "unitless" {
      @error "convert-to-unit: cannot convert a non-number to unitless.";
    }
    @return $value;
  }

  $from-lower: string.to-lower-case($from);
  $target-lower: string.to-lower-case($target-unit);

  // Unitless target: strip unit
  @if $target-lower == "unitless" {
    @return math.div($value, 1#{if($from-lower == "unitless", "", $from-lower)});
  }

  // Same unit, with optional "PX" stringification
  @if $from-lower == $target-lower {
    @return if(
      $stringify and $target-unit == "PX",
      string.unquote("#{math.div($value, 1px)}PX"),
      $value
    );
  }

  // rem → px
  @if $from-lower == "rem" and $target-lower == "px" {
    $px-value: math.div($value, 1rem) * $root-font-size;
    @return if(
      $stringify and $target-unit == "PX",
      string.unquote("#{math.div($px-value, 1px)}PX"),
      $px-value
    );
  }

  // px → rem
  @if $from-lower == "px" and $target-lower == "rem" {
    @return math.div($value, $root-font-size) * 1rem;
  }

  @error "Unsupported conversion: #{$from} to #{$target-unit}";
}

abstracts/functions/_fluid.scss

// Clamp Interpolation Helper
@use "sass:math";
@use "sass:string";
@use "sass:meta";
@use "abstracts/functions/units" as unit;

/// Returns an unquoted clamp() string with a unitless vw coefficient.
/// $output-unit: px | rem | unitless | "PX"
@function generate-clamp-interpolation(
  $min-font,
  $max-font,
  $min-screen: 320px,
  $max-screen: 1200px,
  $output-unit: px
) {
  // Normalize to px so slope is unitless
  $min_px: unit.convert-to-unit($min-font, px, false);
  $max_px: unit.convert-to-unit($max-font, px, false);

  // Linear interpolation in px space
  $slope: math.div(($max_px - $min_px), ($max-screen - $min-screen)); // unitless
  $intercept_px: $min_px - ($slope * $min-screen);                    // px

  // Round for stable CSS output
  $vw_coeff: math.div(math.round($slope * 10000), 100);               // 2 decimals
  $base_px: math.div(math.round($intercept_px * 10000), 10000);       // px

  // Unitless output
  @if $output-unit == unitless {
    @return string.unquote(
      "clamp(#{math.div($min_px, 1px)}, #{$vw_coeff}vw + #{math.div($base_px, 1px)}, #{math.div($max_px, 1px)})"
    );
  }

  // Uppercase PX token
  @if $output-unit == "PX" {
    @return string.unquote(
      "clamp(#{math.div($min_px, 1px)}PX, #{$vw_coeff}vw + #{math.div($base_px, 1px)}PX, #{math.div($max_px, 1px)}PX)"
    );
  }

  // Length output (px or rem)
  $min_out: unit.convert-to-unit($min_px, $output-unit, false);
  $max_out: unit.convert-to-unit($max_px, $output-unit, false);
  $base_out: unit.convert-to-unit($base_px, $output-unit, false);

  @return string.unquote(
    "clamp(#{$min_out}, #{$vw_coeff}vw + #{$base_out}, #{$max_out})"
  );
}

abstracts/functions/_index.scss

// Functions Barrel
//@forward "color";
@forward "number";
//@forward "svg";
@forward "units";
//@forward "vars";
@forward "fluid";

Mixins

abstracts/mixins/_type-scale.scss

// Type Scale Mixins
@use "sass:math";
@use "sass:map";
@use "sass:string";
@use "abstracts/functions" as *;

// Config
$type-prefix: "--ts-" !default;
$type-round-places: 4 !default;

// Modular 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
);

// Static scale tokens
@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);
    $size-rounded: round-to($size, $type-round-places);
    $converted: convert-to-unit($size-rounded, $output-unit, false);
    $formatted: convert-to-unit($converted, $output-unit, true);
    #{ $type-prefix + $label }: #{$formatted};
  }
}

// Container-based fluid 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)
    );

    $min: convert-to-unit(round-to($size * $min-mult, $type-round-places), $output-unit, false);
    $max: convert-to-unit(round-to($size * $max-mult, $type-round-places), $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 {
      #{ $type-prefix + $label }: #{ convert-to-unit($min, $output-unit, true) };
    } @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);
      #{ $type-prefix + $label }: string.unquote("clamp(#{$min-str}, calc(#{$fluid} + #{$bias-str}), #{$max-str})");
    }
  }
}

// Viewport-based slope/intercept 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 math.is-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-to($size * $min-mult, $type-round-places), $output-unit, false);
      $max: convert-to-unit(round-to($size * $max-mult, $type-round-places), $output-unit, false);

      #{ $type-prefix + $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);

      #{ $type-prefix + $label }: #{ generate-clamp-interpolation($min, $max, $min-screen, $max-screen, $output-unit) };
    }
  }
}

// Step-to-step interpolation between explicit sizes
@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 {
    $curr-size: convert-to-unit($value, $output-unit, false);

    @if $prev-size == null {
      #{ $type-prefix + $label }: #{ convert-to-unit($curr-size, $output-unit, true) };
    } @else {
      #{ $type-prefix + $label }: #{ generate-clamp-interpolation($prev-size, $curr-size, $min-screen, $max-screen, $output-unit) };
    }

    $prev-size: $curr-size;
  }
}

// Convenience aliases
@mixin static-scale($steps, $base-size: 1rem, $scale: perfect-fourth, $output-unit: rem) {
  @include generate-static-scale($steps, $base-size, $scale, $output-unit);
}
@mixin fluid-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
) {
  @include generate-fluid-modular-scale($steps, $base-size, $scale, $unit-step, $bias, $min-mult, $max-mult, $interpolation, $output-unit);
}
@mixin 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
) {
  @include generate-interpolated-scale($steps, $scale, $base-size, $min-mult, $max-mult, $output-unit, $min-screen, $max-screen);
}
@mixin step-to-step-scale($steps, $output-unit: rem, $min-screen: 320px, $max-screen: 1200px) {
  @include generate-step-to-step-interpolated-scale($steps, $output-unit, $min-screen, $max-screen);
}

Usage Examples

// Top of entry file
@use "abstracts/functions" as *;
@use "abstracts/mixins/type-scale" as *;

// Container-based tokens with cqw
:root {
  @include generate-fluid-modular-scale((
    h6: -0.5, h5: 0, h4: 1, h3: 2, h2: 3, h1: 4
  ),
    1rem,            // base
    perfect-fourth,  // ratio
    4cqw,            // unit-step
    0.25rem,         // bias
    0.92,            // min
    1.18,            // max
    true,            // interpolation
    rem              // output unit
  );
}

// Viewport fallback if container queries unsupported
@supports not (font-size: 1cqw) {
  :root {
    @include generate-interpolated-scale((
      h6: 0, h5: 1, h4: 2, h3: 3, h2: 4, h1: 5
    ),
      perfect-fourth, 16px, 0.92, 1.18, rem, 320px, 1200px
    );
  }
}

// Usage in components
h1 { font-size: var(--ts-h1); }
h2 { font-size: var(--ts-h2); }
p  { font-size: var(--ts-h5); }

Changelog of Key Fixes

  • Replaced deprecated globals with module calls:
    • map-getmap.get, map-keysmap.keys, unit()math.unit(), unitless()math.is-unitless().
  • Guarded unit extraction with meta.type-of to avoid calling unit() on non-numbers.
  • Replaced unquote() with string.unquote().
  • Made vw slope unitless by computing slope and intercept in px, then converting intercept and bounds to the desired output unit.
  • Standardized @use namespaces to match existing conventions.

Troubleshooting Notes

  • If the build prints a legacy JS API warning, switch to sass-embedded or a recent sass with gulp-sass@^5.1.0.
  • If tokens print too many decimals, set $type-round-places: 3 or 2.
  • Negative fluid centers for small steps are expected when step * unit-step is negative. Increase bias or start steps at zero to avoid a negative calc middle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment