All articles
Converters
32 min readBy DevUtilX Team

CSS Preprocessors: Sass vs SCSS vs Less and Converting to CSS

Compare Sass, SCSS and Less side by side, see what native CSS now covers, and learn how each compiles to CSS, with verified output and pitfalls.

CSS Preprocessors: Sass vs SCSS vs Less and Converting to CSS

You open a stylesheet from an older project and find @import "partials/variables", a handful of @mixin blocks and a file called theme.less. Or a teammate sends you a snippet of indented code with no braces and no semicolons and asks whether it is "Sass or SCSS". Or you simply want to paste some preprocessor code into a browser and get plain CSS back. All three situations come down to the same question: what are CSS preprocessors, how do Sass, SCSS and Less differ, and what does the compiled CSS look like?

This guide is for front-end developers, designers who write CSS, and anyone who inherits a codebase with .scss, .sass or .less files. You will learn where preprocessors came from, what native CSS now does without them, how the three syntaxes compare side by side, how variables, nesting, modules, mixins, functions, maps, loops and guards work, and how the compilation pipeline turns them into CSS. Every compiled output shown below was produced by running Dart Sass 1.107.1 and Less 4.9.1 on the exact input shown, so you can reproduce it. We finish with pitfalls, troubleshooting, build setup, and a decision table for choosing between a preprocessor and plain CSS.

What is a CSS preprocessor?

A CSS preprocessor is a language that extends CSS with programming features and compiles down to ordinary CSS. Browsers never see the preprocessor code. They receive the generated .css file, so the extra features exist only at build time.

The features that define the category are:

  • Variables, so a colour or spacing value is written once and reused.
  • Nesting, so child selectors live inside their parent block.
  • Mixins, reusable blocks of declarations that can take arguments.
  • Functions and operators, for arithmetic, colour manipulation and string handling.
  • Modules or imports, to split a large stylesheet into files and combine them at build time.
  • Control flow, such as loops and conditionals, to generate repetitive rules.

The three names in the title are related but not identical. Sass is the language family and the project, and it has two syntaxes. SCSS is the newer Sass syntax, a superset of CSS that uses braces and semicolons, so any valid CSS file is already valid SCSS. The indented syntax, which people often call simply "Sass", drops braces and semicolons and uses line breaks and indentation instead. Less is a separate language with a similar goal, a CSS superset with its own variable and mixin syntax. The Sass article on Wikipedia gives a compact overview of the language and its syntaxes.

A short history: why preprocessors appeared

CSS was designed as a declarative language for describing presentation, and for most of its first two decades it had no way to name a value. If your brand blue appeared in forty places, you wrote the same hex code forty times and changed all forty by search and replace. There was no way to nest a selector inside another, no way to compute one length from another, and no way to split a stylesheet across files without paying an extra HTTP request per @import.

Preprocessors filled those gaps. Sass began in the mid-2000s as a Ruby project with the indented syntax, and its authors later added SCSS so that existing CSS could be adopted gradually. Less appeared a few years later and was written for JavaScript, which made it easy to run in a browser during development and on Node.js in a build. Other languages such as Stylus followed the same pattern. Over time, build tools such as Grunt, Gulp, webpack and Vite made compiling these files a routine step, and frameworks adopted them for their source code.

The Sass implementation itself changed over the years. The original Ruby implementation was retired, a C++ implementation called LibSass was used widely for speed and has been deprecated, and the reference implementation today is Dart Sass. The official Sass documentation describes Dart Sass as the primary implementation and is the place to check current behaviour, because the language has deprecated some older features, as the examples later in this guide show. Less continues to be maintained, and its official site, lesscss.org, documents the language and its JavaScript API.

The important point for today is that the problems preprocessors solved in 2008 are not all problems any more, and that changes how you should choose.

What native CSS now covers, and what it still does not

Over the last several years browsers have added many of the headline features that once justified a preprocessor. Before reaching for a compiler on a new project, check what plain CSS already gives you.

Feature Native CSS today What a preprocessor still adds
Variables Custom properties, written --brand: #3b82f6 and read with var(--brand) Compile-time constants, maps, and values usable in maths or in selectors and media queries
Nesting Native CSS nesting with & is available in current browsers Selector interpolation, older browser support, and some shorthand patterns such as &__title suffixes
Colour mixing color-mix() and relative colour syntax A large library of colour functions evaluated at build time
Cascade control @layer for ordering groups of rules Nothing directly; layers and preprocessors work together
Calculations calc(), min(), max(), clamp() Compile-time maths with units, rounding and custom functions
File splitting Native @import, which still costs requests unless bundled Modules that are merged into a single file at build time
Loops and conditionals None @each, @for, @if, and Less guards for generating rules
Reusable blocks None as a direct equivalent Mixins with arguments and content blocks

The most useful distinction is when each feature is evaluated. A Sass variable is replaced at build time, so the browser sees a literal value. A CSS custom property lives in the browser, takes part in the cascade and inheritance, can be changed with JavaScript, and can differ between a light and dark theme. A preprocessor variable cannot do any of that, because it no longer exists once the file is compiled. A custom property cannot be used where a value must be known before the CSS is generated, for example inside a loop that produces class names or as the width in a media query.

Because of this, modern projects often combine both: design tokens as custom properties for anything that must change at runtime, and preprocessor features for generating code. You can read more about the native features in MDN's guide to using CSS custom properties, the reference for CSS nesting and the page on @layer.

Here is a small component written with native CSS only. No compiler is involved, and it runs in any current browser:

:root { --brand: #3b82f6; }
.card { color: var(--brand); background: color-mix(in srgb, var(--brand) 20%, white);
  & .title { font-weight: 700; } &:hover { opacity: .9; } }

If this covers your needs, you may not need a preprocessor at all. We come back to that decision at the end, after looking at what the preprocessors do.

Sass, SCSS and Less side by side

The quickest way to see the differences is to write the same component three times. Each version defines a brand colour and a radius, a mixin for a focus ring, and a card with nested rules.

SCSS (file a.scss):

$brand: #3b82f6;
$radius: 6px;

@mixin focus-ring($color: $brand) {
  outline: 2px solid $color;
  outline-offset: 2px;
}

.card {
  padding: 1rem;
  border-radius: $radius;
  background: white;

  &:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
  &__title { font-size: 1.25rem; }
  .btn { @include focus-ring; }
}

Sass indented syntax (file a.sass):

$brand: #3b82f6
$radius: 6px

@mixin focus-ring($color: $brand)
  outline: 2px solid $color
  outline-offset: 2px

.card
  padding: 1rem
  border-radius: $radius
  background: white

  &:hover
    box-shadow: 0 2px 8px rgba(0, 0, 0, .15)
  &__title
    font-size: 1.25rem
  .btn
    @include focus-ring

Less (file a.less):

@brand: #3b82f6;
@radius: 6px;

.focus-ring(@color: @brand) {
  outline: 2px solid @color;
  outline-offset: 2px;
}

.card {
  padding: 1rem;
  border-radius: @radius;
  background: white;

  &:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
  &__title { font-size: 1.25rem; }
  .btn { .focus-ring(); }
}

Compiling each file with its own compiler produced identical CSS from all three, character for character:

.card {
  padding: 1rem;
  border-radius: 6px;
  background: white;
}
.card:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.card__title {
  font-size: 1.25rem;
}
.card .btn {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

Notice three things in the output. The nested &:hover became .card:hover, the &__title suffix became the class .card__title, and the leading zero was added to .15, giving 0.15. The mixin disappeared entirely: it contributed its declarations to .card .btn and produced no rule of its own.

Syntax differences in a table

Aspect SCSS Sass (indented) Less
File extension .scss .sass .less
Blocks Braces and semicolons Indentation and newlines Braces and semicolons
Variable prefix $name $name @name
Define a mixin @mixin name($arg) @mixin name($arg) .name(@arg) { }
Use a mixin @include name @include name, or +name shorthand .name();
Valid CSS is valid input Yes No Yes
Interpolation in selectors #{$var} #{$var} @{var}
Conditionals @if, @else @if, @else Guards with when
Primary implementation Dart Sass Dart Sass less.js

Two differences deserve emphasis. First, because SCSS is a strict superset of CSS, you can rename a .css file to .scss and it still compiles. That makes SCSS the easiest way to migrate. Second, Less mixins look exactly like class selectors. A rule such as .focus-ring { ... } is both a normal class that appears in the output and a mixin you can call, while a Less mixin defined with parentheses, like .focus-ring() { ... }, is not output on its own. This design is concise, but it is a common source of unexpected output.

Variables and how they differ from custom properties

Sass variables begin with a dollar sign and are scoped lexically: a variable defined inside a block is visible only inside it. Less variables begin with @ and have lazy evaluation, which means a Less variable can be used before the line that defines it, and the last definition in a scope wins. Sass variables are evaluated in order, as in most programming languages.

Sass also supports a !default flag. A declaration such as $_private: 4px !default; assigns the value only if the variable has not been set already. That is how library authors expose settings that a consumer can override before loading the library.

Both languages interpolate variables into places where a plain value would not work. In SCSS you write .gap-#{$name} to build a class name, and in Less you write .m-@{i}. Interpolation is something custom properties cannot do, since var() is only valid in value positions, not in selectors, property names or media-query conditions.

Nesting, and why to limit it

Nesting lets a rule contain its children, and the & symbol stands for the parent selector. With it you write pseudo-classes (&:hover), modifiers (&.is-active), and suffixes (&__title).

The danger is that every level of nesting adds a descendant selector to the output. Compiling this deliberately deep SCSS:

.a { .b { .c { .d { .e { color: red; &:hover { color: blue; } } } } } }

produced:

.a .b .c .d .e {
  color: red;
}
.a .b .c .d .e:hover {
  color: blue;
}

A five-level selector like this is expensive to override, because its specificity is high, and it ties the style to a precise HTML structure. A good rule of thumb is to nest no more than three levels and to avoid nesting purely to mirror the HTML. Our guide to CSS formatting and architecture discusses the same problem from the viewpoint of naming conventions and selector design.

Partials, @use and @forward: the module system

Large stylesheets need to be split into files. Sass has two ways to do it, and the difference matters because one is deprecated.

The old way: @import

@import "tokens"; includes another Sass file by pasting its content into the current scope. Everything it defines becomes global, which makes name collisions easy and makes it hard to tell where a variable came from. Dart Sass now warns about it. When we compiled a file containing @import "styles/tokens";, the compiler printed this warning:

DEPRECATION WARNING [import]: Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0.

Less still uses @import as its normal mechanism, and that is not deprecated in Less. The deprecation concerns Sass only.

The modern way: @use and @forward

@use loads a module and gives it a namespace. Members are accessed as namespace.$variable, namespace.function() and @include namespace.mixin, so you always know where a name originates. Each module is loaded once, no matter how many files use it, so its CSS output is not duplicated. Members whose names begin with a hyphen or underscore are private to their module.

@forward re-exports another module's members, so you can build a single entry point for a folder. Here is a small design-system layout we compiled. A partial file begins with an underscore, which tells Sass not to compile it into its own output:

// _tokens.scss
$space: 8px;
$breakpoints: (sm: 600px, md: 900px, lg: 1200px);
$_private: 4px !default;
// _mixins.scss
@use "sass:map";
@use "tokens";

@mixin up($name) {
  @media (min-width: map.get(tokens.$breakpoints, $name)) {
    @content;
  }
}
// _index.scss
@forward "tokens";
@forward "mixins";

The entry file imports the folder through index and gives it the alias ui:

// main.scss
@use "sass:math";
@use "index" as ui;

@function rem($px, $base: 16) {
  @return math.div($px, $base) * 1rem;
}

%message {
  padding: ui.$space;
  border: 1px solid currentColor;
}
.note { @extend %message; color: teal; }
.warn { @extend %message; color: darkorange; }

.sidebar {
  width: rem(320);
  @include ui.up(md) { float: left; }
}

@each $name, $px in (sm: 4, md: 8, lg: 16) {
  .gap-#{$name} { gap: rem($px); }
}

@for $i from 1 through 3 {
  .col-#{$i} { width: math.percentage(math.div($i, 3)); }
}

Compiled with sass main.scss, the output was:

.warn, .note {
  padding: 8px;
  border: 1px solid currentColor;
}

.note {
  color: teal;
}

.warn {
  color: darkorange;
}

.sidebar {
  width: 20rem;
}
@media (min-width: 900px) {
  .sidebar {
    float: left;
  }
}

.gap-sm {
  gap: 0.25rem;
}

.gap-md {
  gap: 0.5rem;
}

.gap-lg {
  gap: 1rem;
}

.col-1 {
  width: 33.3333333333%;
}

.col-2 {
  width: 66.6666666667%;
}

.col-3 {
  width: 100%;
}

This single example exercises most of what makes Sass useful. The following sections take each part in turn.

Mixins: reusable blocks with arguments

A mixin is a named block of declarations, and optionally of whole rules, that you paste into another rule with @include. In the focus-ring example above, the mixin accepted an argument with a default value, so @include focus-ring used the brand colour and @include focus-ring(red) would override it.

The up mixin in the module example shows a more advanced feature. The @content directive marks the spot where the caller's block is inserted. The call @include ui.up(md) { float: left; } handed a block of declarations to the mixin, which wrapped them in a @media (min-width: 900px) query. That produced a media query containing .sidebar, with the breakpoint value looked up from a map. This pattern is the foundation of most responsive helpers, and it centralises breakpoints in one place.

Less mixins work differently. They are defined like classes, with optional parameters, and they are called like functions with a trailing semicolon: .bordered(3px);. Less also supports pattern matching on arguments and guards, covered below, which together provide conditional logic.

Use mixins for patterns that need arguments or that vary by context. Do not use them to share a fixed set of declarations across many selectors, because every @include copies the declarations into each rule, and the compiled CSS grows. For that case, there is a better tool.

Placeholder selectors and @extend

The compiled output above has a rule .warn, .note { padding: 8px; ... }. That came from the placeholder selector %message. A placeholder looks like a class but starts with a percent sign, and it is never output by itself. When another selector writes @extend %message, Sass adds that selector to the placeholder's rule. The shared declarations appear once, with a selector list.

Compare that with a mixin, which would repeat padding and border inside both .note and .warn. With only two selectors the difference is trivial, but with fifty selectors extending one placeholder you get one rule instead of fifty copies.

The cost of @extend is hidden in the selectors. Extending a placeholder rewrites every selector that contains it. We tested this on a rule used inside two different descendant contexts:

%btn { padding: 4px; }
.a .x { @extend %btn; } .b .x { @extend %btn; }

which compiled to:

.b .x, .a .x {
  padding: 4px;
}

That is fine here, but when a placeholder appears in complex nested selectors, the number of generated selectors can multiply. Also, the order of rules in the output can change, which can alter how the cascade resolves ties between equal-specificity rules. A sensible rule is to extend only simple placeholders defined in the same file or module, and to use mixins when the shared block needs arguments or is used inside media queries. Sass does not allow @extend across @media boundaries for good reason.

Less has its own version, written &:extend(.panel);, which behaves similarly, adding the extending selector to the target's rule.

Functions, maths and the division change

Sass functions return a value rather than output declarations. The rem function above converts pixels to rem units: rem(320) became 20rem. Sass also ships built-in modules, loaded with @use "sass:math", @use "sass:map", @use "sass:color", @use "sass:string", @use "sass:list" and others. The module functions replace the old global ones.

Division is the topic that catches the most people. In older Sass, 10px / 2 meant division in some contexts and a plain slash in others, because a slash is also valid in CSS, for example in font: 12px/1.5. The ambiguity led to a deprecation. When we compiled width: (10px / 2); with Dart Sass, the compiler printed:

DEPRECATION WARNING [slash-div]: Using / for division outside of calc() is deprecated and will be removed in Dart Sass 2.0.0.

Recommendation: math.div(10px, 2) or calc(10px / 2)

The correct form is math.div(10px, 2), after @use "sass:math". Other operators are unaffected. We ran a short test to confirm:

@use "sass:math";
.x { a: math.div(10px, 4); b: 10px * 3; c: math.round(2.6); e: calc(100% - #{2 * 8px}); f: 1px + 2px; }
.x {
  a: 2.5px;
  b: 30px;
  c: 3;
  e: calc(100% - 16px);
  f: 3px;
}

Note the #{2 * 8px} inside calc(). Interpolation evaluates the Sass expression and inserts the result as plain text, so the browser receives calc(100% - 16px). Sass maths is also unit-aware: multiplying 10px * 3 gives 30px, and adding incompatible units, such as px and em, is an error rather than a silent mistake.

Less handles division differently, and the default is also protective. In Less 4 the default maths mode requires parentheses for division, so (@base / 2) divides and a bare 2 / 3 does not. We hit this directly: our first attempt, width: percentage(2 / 3);, failed with Error evaluating function 'percentage': argument must be a number, because the bare 2 / 3 was left as text. Wrapping it, percentage((2 / 3)), worked and gave 66.66666667%. Less offers compiler options to change how strict this is, and you should check the current Less documentation for the available math modes before choosing a different one.

Maps, lists and loops

Sass has real data structures. A list is a sequence of values, and a map is a set of key and value pairs, written (sm: 600px, md: 900px). You read a map with map.get($map, key) after loading sass:map, and loop over it with @each $key, $value in $map.

In the module example, @each $name, $px in (sm: 4, md: 8, lg: 16) produced three utility classes, .gap-sm, .gap-md and .gap-lg, with the pixel values converted to rem by the function. The @for $i from 1 through 3 loop generated the three column classes, and math.percentage(math.div($i, 3)) produced the long decimals you see, 33.3333333333% and 66.6666666667%. Sass limits output precision rather than keeping the full value.

Loops are the clearest case where a preprocessor does something CSS cannot. Generating spacing utilities, grid columns, or theme variants from a map is trivial in Sass and impossible in plain CSS. Be aware of the trade-off: every iteration adds rules to your output, so a loop over a large map can produce a large file.

Less has no loop statement. It achieves iteration through recursive mixins with guards, which look unusual at first but work. The next section shows one.

Less guards, loops and operations

A Less guard is a condition attached to a mixin with when. Mixins with the same name are tried in order, and only those whose guard is true run. We compiled this Less file:

@base: 8px;
@theme: dark;

.mixin(@mode) when (@mode = dark) { background: #111; color: #eee; }
.mixin(@mode) when (@mode = light) { background: #fff; color: #222; }

.panel {
  .mixin(@theme);
  padding: @base * 2;
  margin: (@base / 2);
  width: percentage((2 / 3));
  border-color: darken(#3b82f6, 10%);
}

.loop(@i) when (@i > 0) {
  .m-@{i} { margin: (@i * @base); }
  .loop(@i - 1);
}
.loop(3);

.bordered(@w: 1px) { border: @w solid black; }
.box { .bordered(3px); &:extend(.panel); }

and received:

.panel,
.box {
  background: #111;
  color: #eee;
  padding: 16px;
  margin: 4px;
  width: 66.66666667%;
  border-color: #0b63f3;
}
.m-3 {
  margin: 24px;
}
.m-2 {
  margin: 16px;
}
.m-1 {
  margin: 8px;
}
.box {
  border: 3px solid black;
}

Read it from top to bottom. The dark guard matched, so the dark colours were applied. Arithmetic on variables, @base * 2, produced 16px. The recursive .loop mixin counted down from 3 to 1 and wrote one class per step, which is why the classes appear in descending order. darken(#3b82f6, 10%) was evaluated at build time and became #0b63f3. Finally, &:extend(.panel) added .box to the .panel rule, mirroring Sass placeholder behaviour.

How compilation works

Compilation is a four-step pipeline, whichever language you use.

  1. Parse. The compiler reads the source and builds a tree. Syntax errors are caught here, with a line and column.
  2. Resolve. It loads imported or used files, evaluates variables, runs functions, expands mixins and resolves @extend.
  3. Serialise. It writes the tree out as CSS text, in the chosen output style.
  4. Map. If requested, it writes a source map linking every output line back to its source location.

Dart Sass, sass on npm, and other implementations

The reference implementation is Dart Sass, distributed on npm as the sass package. We used version 1.107.1, which reported itself as compiled with dart2js. It provides a command line, a JavaScript API with compile, compileString and asynchronous variants, and standalone binaries. Older implementations are deprecated, so if you see node-sass in an old package.json, plan to migrate to sass.

Less is distributed as the less package, with the lessc command and a less.render function. We used version 4.9.1.

Output styles

Dart Sass offers expanded, the readable default, and compressed, which removes whitespace and optimises where it safely can. Compiling the same module example with --style=compressed gave a single line:

.warn,.note{padding:8px;border:1px solid currentColor}.note{color:teal}.warn{color:#ff8c00}.sidebar{width:20rem}@media(min-width: 900px){.sidebar{float:left}}.gap-sm{gap:.25rem}.gap-md{gap:.5rem}.gap-lg{gap:1rem}.col-1{width:33.3333333333%}.col-2{width:66.6666666667%}.col-3{width:100%}

Notice that the compressed output also shortened darkorange to #ff8c00 and 0.25rem to .25rem. This is a light minification that comes with the output style, not a full minifier. For production, most teams still run a dedicated minifier afterwards, or let the bundler do it. Our guide to CSS minification and critical CSS explains what a full minifier changes, such as merging rules and removing redundant declarations.

Less has a compress option for the same purpose. Using the JavaScript APIs of both compilers on small inputs, we got the same minified output from both:

.a{color:#3b82f6}.a .b{margin:8px}

Source maps

A source map is a JSON file that lets browser developer tools show the original .scss line when you inspect a rule, rather than the compiled line. We compiled the module example with sass --source-map main.scss out.css, which wrote out.css.map and appended /*# sourceMappingURL=out.css.map */ to the CSS. The map listed its sources, main.scss, _tokens.scss and _mixins.scss, which confirms that the compiler tracks the code across modules. Source maps are for development. Ship them to production only if you intend to expose your source.

Converting preprocessor code to CSS with DevUtilX

You do not need a local toolchain to see what your code compiles to. The DevUtilX tools run in your browser, so you can paste code, get CSS, and carry on.

  1. Pick the tool that matches your syntax: SCSS to CSS for braces and semicolons, Sass to CSS for indented syntax, or Less to CSS for .less files.
  2. Paste your code into the input editor.
  3. Run the conversion.
  4. Read the CSS output. If you pasted a snippet that uses variables, nesting or mixins that you defined in the same snippet, they will be resolved in the result.
  5. Copy the CSS to your project, or compare it with what your build produces.

Your input is processed locally in your browser and is not uploaded, which is useful when the stylesheet belongs to a private project. The tool compiles the text you paste, so there is nothing for it to load from disk. If your code starts with @use "tokens" or @import "variables", the browser has no tokens file to read. Paste the contents of those files above your code, or inline them into one snippet. Errors are reported with a message and position, such as the Undefined variable message we saw when a variable was misspelled, so you can fix the line and run again.

After converting, tidy the result with a formatter, and if you need a smaller file, minify it. For readable formatting rules see our CSS formatting and architecture guide.

Compiling in code: JavaScript, Python and the shell

For anything beyond a one-off conversion, call the compiler from code. These examples were all run, and the output is shown.

JavaScript (Node.js)

const sass = require("sass");
const less = require("less");

const scss = `$c: #3b82f6; .a { color: $c; .b { margin: 4px * 2; } }`;
const result = sass.compileString(scss, { style: "compressed" });
console.log(result.css);

less
  .render("@c: #3b82f6; .a { color: @c; .b { margin: (4px * 2); } }", { compress: true })
  .then((out) => console.log(out.css));

Output of both lines:

.a{color:#3b82f6}.a .b{margin:8px}
.a{color:#3b82f6}.a .b{margin:8px}

compileString compiles source text, and compile takes a file path and can resolve @use. less.render returns a promise.

Python

Python has no official Dart Sass binding that we can vouch for here, so the dependable approach is to call the Node command line as a subprocess. This works in build scripts and static site generators:

import subprocess

src = "$c: #3b82f6; .a { color: $c; .b { margin: 4px * 2; } }"
p = subprocess.run(
    ["./node_modules/.bin/sass", "--stdin", "--style=compressed", "--no-source-map"],
    input=src, capture_output=True, text=True,
)
print(p.stdout.strip(), p.returncode)

bad = subprocess.run(
    ["./node_modules/.bin/sass", "--stdin"],
    input=".a { color: $nope; }", capture_output=True, text=True,
)
print(bad.returncode, bad.stderr.splitlines()[0])

We ran it and got:

.a{color:#3b82f6}.a .b{margin:8px} 0
65 Error: Undefined variable.

A failed compile returned exit code 65 and an error on standard error, so always check returncode rather than assuming the output is valid.

Shell

# one file to one file, with a source map
npx sass main.scss out.css

# compressed, no source map
npx sass --style=compressed --no-source-map main.scss out.min.css

# watch a folder
npx sass --watch styles:dist

# Less
npx lessc g.less out.css

We ran sass and lessc this way against the sample files. The --watch flag recompiles on change and is for development, not for CI.

Build setup: CLI, Vite and webpack

You have three common ways to compile in a project.

The CLI is the simplest. Add sass as a dev dependency and a script such as "build:css": "sass --style=compressed src/main.scss dist/main.css". It suits small sites and libraries.

Vite compiles .scss, .sass and .less files when you install the matching package, with no further configuration. You install sass and then import "./main.scss" in your JavaScript entry, or link it from your HTML entry. Vite handles hot reloading in development and bundling for production. For Less, install less instead. We did not run a Vite project for this article, so check the Vite documentation for the options that pass settings through to the compiler.

webpack needs a chain of loaders. The sass-loader compiles Sass, css-loader resolves url() and imports, and either style-loader or the MiniCssExtractPlugin loader delivers the result, in development and production respectively. The rule below is the usual shape and is shown for orientation, not as output we ran:

module: {
  rules: [
    { test: /\.s[ac]ss$/i, use: ["style-loader", "css-loader", "sass-loader"] },
  ],
},

Loaders run from right to left, so Sass compiles first. Less uses less-loader in the same position.

Whichever route you take, pin the compiler version in package.json and commit the lockfile. Compiler behaviour, especially around deprecations and rounding, changes between versions, and a surprise output change in a CI run is hard to debug.

Real-world use cases

Design systems and theming. Tokens such as colours, spacing and type scales live in a map. Loops generate utility classes. Many teams then output the tokens as custom properties for runtime theming, using interpolation, for example --space-#{$name}: #{$value};.

Large codebases split across teams. The module system, with @use and namespaces, prevents one team's variable from overwriting another's. That is hard to achieve with a single global CSS file.

Frameworks and component libraries. Popular frameworks have shipped their source in Sass or Less so that you can customise variables, such as the primary colour, before compiling. If you inherit a project that depends on this, you need the compiler for as long as you use that mechanism.

Responsive helpers. A single breakpoint mixin backed by a map, as in the up mixin above, keeps every media query consistent across hundreds of files.

Legacy maintenance. Many older projects are built on Less or Sass and will stay that way. Being able to read the code and to convert snippets to CSS is a practical skill in itself.

Email and static sites. Build tools can inline compiled CSS into templates. Preprocessors help produce consistent styles across many templates, although email clients have their own rendering limits that no compiler can fix.

Common mistakes and pitfalls

  • Nesting too deeply. As the five-level example showed, nesting multiplies selector length and specificity. Keep it to about three levels.
  • Overusing @extend. It rewrites selectors, can reorder rules and surprise the cascade, and it complicates debugging. Prefer placeholders in the same module, or use a mixin.
  • Using @import in new Sass code. It is deprecated and will be removed in Dart Sass 3.0.0, as the warning states. Use @use and @forward.
  • Using / for division in Sass. It triggered the slash-div warning and will be removed in Dart Sass 2.0.0. Use math.div or calc().
  • Forgetting that Sass variables disappear at build time. You cannot read one from JavaScript or change it per theme at runtime. Use a custom property for that.
  • Assuming a Less mixin is invisible. A mixin defined without parentheses is also output as a class. Add the parentheses if you do not want that.
  • Dividing in Less without parentheses. As we saw, percentage(2 / 3) failed. Write (2 / 3).
  • Mixing units. Sass will raise an error for incompatible units. Convert units explicitly, or use calc() to combine them at runtime.
  • Generating huge output with loops. A loop over a big map multiplies rules. Check the compiled size after each change.
  • Committing compiled CSS and source together without a rule. Decide whether the compiled file is a build artefact, and ignore it in version control if so.
  • Letting the output style drive the review. Reviewers should read the source files. When you need to compare outputs, use a diff tool, as described in our guide to comparing files and code diffs.

Performance, security and accessibility

Performance

Compiling is a build-time cost, and the runtime cost is the size and structure of the CSS you ship. A preprocessor can make that cost worse without anyone noticing: a loop, a heavily used mixin or an unrestricted @extend can add thousands of lines. Measure the compiled and compressed size, not the source size. Using @use instead of @import also avoids emitting the same module's CSS twice. For large projects, the compile time itself matters in development, which is why incremental builds in Vite or webpack and the --watch mode exist.

On the delivery side, a stylesheet that blocks rendering delays the first paint. Compile one critical file and defer the rest if your page needs it, a technique explained in our article on critical CSS.

Security

Treat a preprocessor like any other build-time program. Compiling untrusted input on a server, for example to let users upload themes written in Sass or Less, is risky. Imports can read files from the server's file system, and Less in particular supports features that evaluate JavaScript in older configurations, so check its documentation and disable such features if you accept input from users. If you must compile user-supplied stylesheets, run the compiler in a sandbox, give it no access to sensitive files, set a timeout and a memory limit, and validate the output. Compiling in the browser, as the DevUtilX tools do, keeps the input on the user's own machine and avoids the question.

Also keep the compiler package up to date and review third-party style packages you import, as they are code that runs during your build.

Accessibility

Preprocessors do not change accessibility on their own, but they make some accessible patterns easier and others easier to break. A colour function can derive a darker variant of a brand colour, but a computed colour is not automatically accessible. Check the contrast ratio of the compiled result against the WCAG thresholds, not the source variable. The focus-ring mixin used earlier shows a good pattern: put a visible focus style in one mixin and apply it everywhere, so no component ships without it. Likewise, a mixin that wraps @media (prefers-reduced-motion: reduce) lets you honour that user preference consistently. Never remove outlines globally in a mixin without providing a replacement.

Troubleshooting

Symptom: Undefined variable (Sass) or variable @x is undefined (Less)

Cause. The variable name is misspelled, defined after use in Sass, or defined in a module that has not been loaded with @use. We reproduced it with .a { color: $nope; } in Sass and .a { color: @nope; } in Less. Sass exited with status 65 and printed Error: Undefined variable., and Less reported a NameError.
Fix. Check the spelling, define the variable first in Sass, and access module variables with their namespace, for example ui.$space.

Symptom: a DEPRECATION WARNING [import] appears

Cause. The code uses @import in Sass.
Fix. Replace it with @use or @forward, and add the namespace to every reference. The official migrator mentioned in the warning can automate much of the change.

Symptom: a DEPRECATION WARNING [slash-div] appears

Cause. A slash was used for division.
Fix. Use math.div(a, b) after @use "sass:math", or use calc() when the result is a runtime length.

Symptom: the output contains a rule you did not expect

Cause. In Less, a mixin defined without parentheses is also emitted as a class. In Sass, an @extend rewrote a selector list.
Fix. Add parentheses to the Less mixin definition, or switch to a mixin. Read the compiled CSS to see which selectors were produced.

Symptom: styles apply in the wrong order or lose to another rule

Cause. Deep nesting raised the specificity, or @extend moved the rule earlier in the output.
Fix. Flatten the selectors, and prefer low specificity. If the conflict spans several sources, @layer can order them explicitly, which is an area where native CSS now helps.

Symptom: @use rules must be written before any other rules

Cause. In Sass, @use must appear at the top of a file, before ordinary rules, though it may follow comments and @forward rules and variable declarations that configure modules.
Fix. Move the @use lines to the top.

Symptom: Less reports argument must be a number in a function

Cause. An expression such as 2 / 3 was left unevaluated because Less 4's default maths mode requires parentheses for division.
Fix. Write (2 / 3), or review the compiler's maths option in the Less documentation.

Symptom: browser dev tools show compiled line numbers, not source

Cause. No source map was generated, or the map file is not served.
Fix. Compile with source maps enabled, confirm the sourceMappingURL comment at the end of the CSS, and make sure the .map file sits where the comment points.

Symptom: the pasted snippet fails in an online converter but works in your project

Cause. The snippet depends on files, variables or a framework that are loaded elsewhere, so the converter has nothing to resolve.
Fix. Include the missing definitions in the snippet, or compile with your own build for code that depends on other files.

Best practices and checklist

  • Start with native CSS. Use custom properties, nesting and @layer first, and add a preprocessor only for features you can name.
  • Use SCSS for new Sass code. It is a CSS superset, tooling support is broad, and it is the easiest to migrate to and from.
  • Use @use and @forward, never @import, in new Sass code.
  • Use math.div, not /, for division.
  • Nest at most three levels and avoid nesting that mirrors the HTML.
  • Prefer mixins with arguments over @extend, and use placeholders only for simple, same-module sharing.
  • Keep tokens in one module and expose them as custom properties when they must change at runtime.
  • Put breakpoints in a map and use one mixin to apply them.
  • Pin compiler versions and commit the lockfile.
  • Generate source maps in development and decide deliberately about production.
  • Review the compiled output after large changes, checking its size and selectors.
  • Minify and compress at build time. Compress with gzip or Brotli on the server.
  • Check colour contrast on compiled colours, and include focus styles in shared mixins.
  • Do not compile untrusted input on a server without a sandbox.

A short pre-merge checklist: Does it compile with no deprecation warnings? Is the largest selector three levels or fewer? Did the compiled size change as expected? Do the source maps resolve? Is every colour pair checked for contrast?

Which one should you use? A decision table

Situation Recommendation Reason
New small site or component, modern browsers Plain CSS with custom properties and nesting No build step, runtime theming
New project that needs loops, maps or generated utilities SCSS with Dart Sass Mature module system, active development
Team already has a large .scss codebase Stay on SCSS and migrate @import to @use Lower risk than a rewrite
Team prefers minimal punctuation Sass indented syntax Same features as SCSS, different surface
Existing Less codebase or a framework built on Less Keep Less, convert gradually if needed Rewrites rarely pay for themselves
One-off snippet to convert A browser-based converter such as the DevUtilX tools No setup, local processing
Theming that changes at runtime, for example dark mode Custom properties, optionally generated by a preprocessor Preprocessor variables vanish at build time
Untrusted user-supplied styles Plain CSS with strict validation Compilers add attack surface

Comparison of alternatives

Approach Syntax Variables Nesting Loops and logic Runtime changes Build step
Plain CSS CSS Custom properties Native & nesting No Yes No
SCSS CSS superset $var, build time Yes Yes No Yes
Sass indented Indentation $var, build time Yes Yes No Yes
Less CSS superset @var, build time Yes Guards and recursion No Yes, or in-browser for development
PostCSS with plugins CSS plus plugins Depends on plugins Depends on plugins Limited Depends Yes

PostCSS is worth a note. It is a tool for transforming CSS with plugins, and it does not define a language of its own. It is often used after a preprocessor for tasks such as adding vendor prefixes, and some teams use it alone with plugins that mimic a few preprocessor features. Whether a plugin set suits you depends on the plugins, so evaluate them individually.

Migrating between syntaxes

You will sometimes need to move code between the three languages, for example when a client asks you to port a Less theme to SCSS or when you want to convert the indented syntax to SCSS for tooling reasons. The list of mappings in the syntax table is a good start, but a mechanical conversion has traps.

Start with the variable prefix, which changes from @ to $. Then convert mixin definitions: Less .name(@a) { } becomes @mixin name($a) { }, and every call .name(1); becomes @include name(1);. Guards become @if and @else inside the mixin. Recursive loop mixins become @for or @each and are usually simpler as a result. Less operations that rely on lazy evaluation, where a variable is used before its definition, need to be reordered because Sass is evaluated in order.

The indented syntax converts to SCSS by adding braces and semicolons, and the two compile to the same CSS, as our side-by-side test showed. Do not assume that Dart Sass ships a converter for it. Check the current documentation, or do the conversion by hand and compare the compiled output.

After any migration, compile both versions and compare the CSS output with a diff tool. If the two outputs match, the port is faithful. Differences in number formatting, such as .5 versus 0.5, are harmless, but a changed selector or declaration order deserves a closer look. Since compilation is deterministic, this comparison is a quick and reliable regression test, and it is one of the best reasons to keep the compiled CSS available in your CI artefacts.

Native CSS features worth knowing before you adopt a preprocessor

Because the case for a preprocessor depends on what the browser can do, it is worth spending a moment on how each native feature behaves compared with the build-time version.

Custom properties cascade and inherit. If you set --brand on :root and override it inside .dark, every element in that subtree sees the new value. That is exactly what a theme switch needs. You can also give var() a fallback, as in var(--brand, #3b82f6), and change values from JavaScript with element.style.setProperty. The caveat is that custom properties are strings until used, and a typo gives an invalid value at computed-value time rather than a compile error. A preprocessor catches the misspelled variable immediately, which we saw in the Undefined variable test.

Native nesting works with the & selector, and the specification has relaxed some early restrictions about how a nested selector must begin. Check the MDN page linked earlier for the version of the rules supported by the browsers you target. Native nesting does not provide suffix concatenation such as &__title, because in CSS & is a selector, not a string. If your naming convention depends on suffixes, a preprocessor remains convenient.

color-mix() blends two colours in a chosen colour space at runtime, so a hover colour can follow a theme variable. Sass colour functions such as color.adjust work on a fixed value at build time. Our test of color.adjust(#3b82f6, $lightness: -10%) returned a colour in rgb() notation with long decimals, which shows that build-time colour maths can produce verbose results you may want to round or review.

@layer lets you declare the order of groups of rules, so that a reset, a library and your own styles resolve conflicts predictably regardless of specificity. It complements a preprocessor rather than replacing one. A common structure is to declare the layer order once, then have Sass modules write their rules inside @layer blocks.

@import in plain CSS still triggers an additional request per file unless a bundler inlines it, so preprocessors that merge files at build time retain an advantage for delivery, though HTTP/2 and bundlers reduce the gap.

The practical conclusion is a hybrid one. Use native features for what the browser should know about, and a preprocessor for what only the build needs to know. If a project has no need for the second category, drop the build step and enjoy faster feedback and fewer dependencies.

FAQ

What is the difference between Sass and SCSS?
Both are Sass. SCSS uses braces and semicolons and is a superset of CSS, so valid CSS is valid SCSS. The indented syntax, usually called Sass, uses indentation and newlines instead. They have the same features and compile to the same CSS.

Which is better, Sass or Less?
Neither is universally better. Sass, through Dart Sass, has a module system, a large built-in library and very active adoption. Less has a smaller feature set and a simple model that some teams prefer. Choose based on your existing codebase and ecosystem, and do not rewrite a working project just to switch.

Do I still need a CSS preprocessor in modern projects?
Often not. Custom properties, native nesting, color-mix() and @layer cover many common needs. Preprocessors still help when you need loops, maps, mixins with arguments or compile-time maths.

Can browsers read SCSS, Sass or Less files directly?
No. Browsers understand only CSS, so the files must be compiled first. Less can run in the browser through its JavaScript library during development, but that is not recommended for production.

Is @import removed from Sass?
It is deprecated, not yet removed. Dart Sass states that it will be removed in version 3.0.0. New code should use @use and @forward.

Why does 10px / 2 give a warning?
A slash is ambiguous in CSS, so Sass deprecated it for division. Use math.div(10px, 2), which gave 5px in our test, or calc(10px / 2).

What does the underscore in _tokens.scss mean?
It marks a partial. Sass does not compile a partial into its own CSS file. You load it from another file, and you can leave off the underscore and extension, as in @use "tokens".

What is the difference between a mixin and @extend?
A mixin copies its declarations into each rule that includes it and can take arguments. @extend adds the selector to an existing rule, so the declarations appear once, but it rewrites selectors and can reorder output. In the module example, extending %message produced one shared rule for .warn and .note.

Should I use compressed output or a separate minifier?
Compressed output is a light minification. Many teams use it for simplicity, others run a dedicated minifier afterwards for deeper optimisation. Either way, enable gzip or Brotli on the server.

Are source maps safe to deploy?
They expose your source structure, so deploy them only if that is acceptable. Many teams keep them for development and error tracking only.

Can I convert CSS back to SCSS?
Valid CSS is already valid SCSS, so rename the file. You cannot recover variables or mixins automatically, because compilation discards them.

Is it safe to paste my stylesheet into an online converter?
Only if the conversion runs locally. The DevUtilX converters process your code in the browser, but you should still avoid pasting secrets or private paths into any tool.

Why does the compiled number look so long, like 33.3333333333%?
Sass keeps a limited number of decimal places of precision when it writes numbers. You can round the value in the source, for example with math.round, if a shorter number matters.

Conclusion

Preprocessors solved real problems when CSS had no variables, no nesting and no way to split a file cleanly. Today, native CSS handles many of those problems, and what remains is generation: loops, maps, mixins with arguments and compile-time maths. Choose SCSS for new Sass code, use @use and @forward, write division with math.div, keep nesting shallow, and treat @extend with care. Whichever language you inherit, the compiled CSS is the truth, so read it.

When you want to see that CSS right now, paste your code into the SCSS to CSS, Sass to CSS or Less to CSS converter. It runs in your browser and nothing is uploaded. To keep learning, read our guides on CSS formatting and architecture and CSS minification and critical CSS.

Further reading

Try the tools

Further reading

Related articles