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.
- 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.
// 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);
}// 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}";
}// 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})"
);
}// Functions Barrel
//@forward "color";
@forward "number";
//@forward "svg";
@forward "units";
//@forward "vars";
@forward "fluid";// 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);
}// 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); }- Replaced deprecated globals with module calls:
map-get→map.get,map-keys→map.keys,unit()→math.unit(),unitless()→math.is-unitless().
- Guarded unit extraction with
meta.type-ofto avoid callingunit()on non-numbers. - Replaced
unquote()withstring.unquote(). - Made vw slope unitless by computing slope and intercept in px, then converting intercept and bounds to the desired output unit.
- Standardized
@usenamespaces to match existing conventions.
- If the build prints a legacy JS API warning, switch to
sass-embeddedor a recentsasswithgulp-sass@^5.1.0. - If tokens print too many decimals, set
$type-round-places: 3or2. - Negative fluid centers for small steps are expected when
step * unit-stepis negative. Increasebiasor start steps at zero to avoid a negative calc middle.