Two conversions happening in one pass
Moving a stylesheet from LESS to SASS changes two things at once, and mixing them up is where most conversions go wrong. The dialect changes, so @brand becomes $brand and a mixin stops being an ordinary class. The syntax changes as well, so braces and semicolons disappear and indentation carries the nesting. A find-and-replace gets the punctuation right and the dialect wrong, and the file dies at the first breakpoint. The bench above separates the two, and reports every construction LESS supports that SASS spells another way.
The at-sign means two different things in LESS
In LESS, @ opens a variable name. In CSS, the same character opens an at-rule. LESS lives with the collision because its parser knows @media from @brand. A converter written as one regular expression does not, and the output looks fine until a browser reaches the breakpoint.
$brand: #2f6fb2
$import "core/reset"
$media (min-width: 62rem).btn
padding: $radius$brand: #2f6fb2
@import "core/reset"
@media (min-width: 62rem).btn
padding: $radiusThe converter here keeps a list of every CSS and SASS at-rule and steps over those names. Everything else with a leading @ is treated as a variable. The At-rules kept counter shows how many names survived untouched, which is the fastest way to confirm the pass understood your breakpoints.
| LESS | What it means | SASS output |
|---|---|---|
@brand: #2f6fb2 | Variable declaration | $brand: #2f6fb2 |
@media (min-width: 62rem) | CSS at-rule | Kept as written |
@{brand} | Name pasted into a selector or string | #{$brand} |
@media @bp-wide | Variable holding a whole query | @media #{$bp-wide} |
~"(min-width: 62rem)" | Escaped literal | unquote("(min-width: 62rem)") |
@@name | Variable named by another variable | Nothing equivalent, flagged |
@arguments | Every argument as one list | Needs a $args... parameter, flagged |
@import (reference) "x" | Import with LESS-only options | @import "x", option dropped and flagged |
The @media @bp-wide row is the one people miss. LESS reads a bare variable name in a media prelude and pastes its contents. SASS parses the prelude as CSS, so a bare $bp-wide sits there as literal text and the query never matches. The name has to be wrapped as #{$bp-wide}. The converter does that wrapping and says so in the notes, because the broken form still compiles without an error.
Mixins stop being selectors
LESS has no separate idea of a mixin. Any class is callable, so .truncate; on a line of its own pastes that rule set into the current block. SASS splits the two roles: @mixin declares something with no output of its own, and @include calls it. Rewriting the punctuation is easy. Deciding which role each of your classes was playing is not.
| LESS form | SASS form | Notes |
|---|---|---|
.pill(@bg; @fg: #fff) { } | @mixin pill($bg, $fg: #fff) | Semicolon separator becomes a comma |
.pill(@brand); | @include pill($brand) | Direct |
.truncate; | @include truncate | Only works when .truncate is a mixin, not a class |
.btn:extend(.card) | @extend .card inside the block | Moves out of the selector |
#ns > .helper(); | @include ns.helper() | Needs @use "ns", flagged |
.pill(@brand) !important; | No equivalent | LESS pushes the flag onto every declaration inside |
The semicolon in .pill(@bg; @fg: #fff) is a LESS habit worth understanding before the converter rewrites it. LESS reads commas as list separators inside a single argument, so .shadow(0 0 2px red, 0 0 4px blue) passes one argument, and the semicolon form passes two. SASS reads commas as argument separators and treats a parenthesised group as one value, so the swap is safe in both directions. Load the sample and watch @bg and @fg land as two parameters.
The class-that-is-also-a-mixin case has no clean answer, so the bench flags it rather than guessing. When .truncate appears in your file as a rule and as a call, LESS emits .truncate to the compiled CSS and pastes its declarations at the call site. SASS refuses to do both under one name.
Pick one. Keep the class and change the call to @extend .truncate when the class is used in your HTML. Turn the rule into @mixin truncate when nothing references the class name, and accept that the standalone selector disappears from the output CSS. The notes panel names every class caught in this position.
Guards become an @if inside the mixin
A LESS guard hangs a condition off the mixin signature with when. SASS has no such position, so the condition moves inside the body as an @if, and the whole block indents one level deeper. The converter performs that reshape, which is why the output has a level of nesting the source did not.
.pill(@bg) when (iscolor(@bg)) {background: @bg;}@mixin pill($bg)@if meta.type-of($bg) == color
background: $bgThe guard test functions have no SASS names, so the converter passes them through unchanged and flags the line. Swapping them is mechanical once you load sass:meta and sass:math.
| LESS guard test | SASS replacement |
|---|---|
iscolor(@v) | meta.type-of($v) == color |
isnumber(@v) | meta.type-of($v) == number |
isstring(@v) | meta.type-of($v) == string |
ispixel(@v) | math.unit($v) == "px" |
ispercentage(@v) | math.unit($v) == "%" |
isdefined(@v) | meta.variable-exists("v") |
@a > 0, @b > 0 | $a > 0 or $b > 0 |
default() | No equivalent, restructure the mixin |
Guards do more than filter. LESS resolves a call by trying every mixin with a matching name, keeping the ones whose arity fits and whose guard passes, then merging their output. Four definitions of .button with different guards is normal LESS. SASS allows one @mixin per name, so those four have to collapse into one mixin with default arguments and an @if chain. No converter does that merge, because the merge is a design decision about which branch wins.
Rules the indented syntax enforces
SASS files carry the .sass extension, and a compiler pointed at .scss will not read them. The parser follows a shorter set of rules than the braced form, and each one shapes the output above.
- Indentation is the only nesting. Braces are a parse error, semicolons at end of line are a parse error, and mixing tabs with spaces inside one file is a parse error. The indent control above writes whichever you pick, consistently.
- One statement per line, with no continuation. A
box-shadowwritten across three source lines has to become one line. The converter joins them, so a long shadow list or a grid template comes out wide. Reflow it by hand or store the value in a variable. - Double-slash comments never reach the CSS. They stay in the source and vanish at compile time. A
comment survives into the output, and every line after the first has to indent past the opening line, which the converter handles. - Mixins that take a body use indentation for it. A
@includewith a block puts that block on the following lines, one level in, and the mixin reads it through@content. - Selectors and properties never share a line. LESS lets you write
.btn { padding: 4px; }in one line. That collapses into two lines here, which is why the output line count runs above the source on compact files.
Where LESS and SASS disagree about meaning
Everything above is translation. What follows is not, and no converter catches any of it, because both files parse cleanly and only the compiled CSS differs.
Variable resolution runs in opposite directions. LESS evaluates variables lazily and lets a later definition in the same scope win. SASS reads top to bottom and takes the value standing at the moment of use.
@size: 10px;.a { width: @size; }
@size: 20px;$size: 10px
.a
width: $size
$size: 20pxEvery LESS codebase that redefines a variable after a theme import depends on this, and the converted file silently picks the other value. Search your source for names declared more than once before you trust any converted output.
- The slash changed sides. LESS reads
@gutter / 2as division. Dart Sass reads a slash as a separator and reports the old behaviour as removed, so the same expression becomesmath.div($gutter, 2)after loadingsass:math. The notes flag any slash sitting beside a variable. - Colour helpers share few names.
darken(),lighten(), andmix()exist in both.fade(),fadein(),tint(),shade(), andcontrast()do not, and their nearest SASS forms take different arguments in a different order. Convert those by reading what each call produced, rather than by name. - LESS reads files at compile time.
data-uri(),image-size(), andimage-width()open the file on disk. SASS has no file access, so that work belongs in a build step or an inlined result. - Detached rule sets have no home. LESS stores a block in a variable and replays it later. SASS keeps blocks in mixins and passes them through
@content, which is a rewrite rather than a substitution. - Namespaces became module loading.
#ns > .helper()reaches into a nested block. The SASS answer is a partial loaded with@use, called as@include ns.helper(). The file split is the work, not the syntax.
An order that keeps the diff readable
- Convert the variables file first. It has no mixin calls and no guards, so it lands clean, and every other file leans on the names it declares.
- Convert the mixin library second. This is where the guards and the arity overloads live, so it takes the longest and everything downstream depends on the decisions you make here.
- Convert components last, one file per pass. Read the notes panel before you read the output pane. The output pane always looks plausible.
- Fix flagged lines in the SASS, never in the LESS. Editing the source to make the converter happier leaves you maintaining two dialects at the same time.
- Diff the compiled CSS, not the source. Run
lesscon the old tree andsasson the new one, push both through the same formatter, and compare. Matching CSS is the only proof the pass worked, and it catches the lazy-evaluation trap above, which reading the source never will. - Delete the LESS build once the CSS matches. Keeping both alive means every new rule gets written twice, and one of the two copies starts drifting within a week.
What this converter does not attempt
- Nothing is evaluated.
darken(@brand, 12%)comes out asdarken($brand, 12%)with no arithmetic performed. The pass rewrites text and reports what it changed. - One file at a time. Import paths are not followed, so a mixin defined in another partial reads as undefined here. The notes name it and suggest the
@useline rather than pretending the call resolves. - Colour functions keep their LESS names. Renaming
fade()to a SASS call means choosing betweencolor.adjust()andcolor.scale(), and the two produce different colours from the same input. - Overloaded mixins stay separate. Four definitions of one name come out as four
@mixinblocks with the same name, which is a compile error you have to resolve by merging them. - Plugin calls pass through. Anything a
@plugindirective introduced is unknown to this pass and to any SASS compiler. - Long values get long lines. A multi-line value is joined because the indented syntax has no continuation character. That is correct and ugly at the same time.
Everything runs in this tab. The parser, the rewrite passes, and the review notes are JavaScript on the page, so a private theme file or an unreleased brand palette stays on your machine. Load the page once, drop the network, and the bench keeps converting.
