A file compiling is not a file converting
LESS and SCSS read like siblings. Braces stay, semicolons stay, nesting stays, both comment styles work in each. Swap every @ for a $ and the Sass compiler stops complaining, which is where the trouble starts. Sass parses most LESS habits without an error, then writes different CSS from them, so a bad conversion reaches the browser instead of the terminal. The bench above sorts its own work into three groups, because the group a line falls into tells you whether you have to read it.
| Group | What the pass did | What you do with it |
|---|---|---|
| Rewritten | An exact swap with the same meaning on both sides | Skim it |
| Check | Rewritten, with a behaviour difference sitting behind the new spelling | Read the note, then diff the compiled CSS |
| Blocked | A LESS construction with no SCSS spelling, left exactly as written | Redesign that part before the file builds |
The at-sign carries three jobs in LESS
In LESS the @ opens a variable name, opens an at-rule, and marks interpolation as @{...}. Sass spreads those across two characters: $ for a name, #{...} for interpolation, @ reserved for at-rules alone. One regular expression over the file gets a third of the job right and breaks the rest.
| LESS | What LESS reads there | SCSS |
|---|---|---|
@radius: 6px; | Variable declaration | $radius: 6px; |
@media (min-width: 62rem) | CSS at-rule | Kept as written |
@{state} | A name pasted into a selector, a property, or a string | #{$state} |
@media @bp-wide | A variable holding a whole query | @media #{$bp-wide} |
~"(min-width: 62rem)" | Escaped literal | unquote("(min-width: 62rem)") |
@@side | The variable named by another variable | No equivalent, use a map |
@import (reference) "x" | Import with LESS-only options | @use "x", options dropped |
@plugin "x" | JavaScript running inside the compiler | Nothing, delete the line |
The fourth row is the one that ships broken. Watch what a careless rename does to a stored breakpoint.
@bp-wide: ~"(min-width: 62rem)";@media @bp-wide {.card { padding: 32px; }}$bp-wide: "(min-width: 62rem)";@media $bp-wide {.card { padding: 32px; }}@media $bp-wide {.card { padding: 32px; }}No error, no warning, no matching device. Sass parses a media prelude as CSS text, finds nothing to resolve in $bp-wide, prints it, and moves on. The fix is @media #{$bp-wide}, which is why this converter keeps a list of every CSS and Sass at-rule and steps over those names rather than treating a leading at-sign as proof of a variable.
Every class in LESS is already a mixin
LESS never separated the two ideas. Write .truncate { ... } and you own a class in the compiled CSS plus a mixin callable as .truncate; from any block. Sass holds the roles apart with @mixin and @include, so a converter has to decide which role each of your rules was playing, and for some rules both answers are correct.
| LESS form | SCSS form | What moved |
|---|---|---|
.pill(@bg; @fg: #fff) { } | @mixin pill($bg, $fg: #fff) { } | The semicolon separator becomes a comma |
.pill(#2f6fb2); | @include pill(#2f6fb2); | Direct, no judgement needed |
.truncate; | @include truncate; | Correct only when the name is a mixin, not a class your markup uses |
.btn:extend(.card) | @extend .card; inside the block | Off the selector, into the body |
.btn:extend(.card all) | @extend .card; | The all keyword has nowhere to go |
#ns > .helper(); | @include ns.helper(); | Needs the block moved into its own partial |
.pill(@brand) !important; | No equivalent | LESS pushed the flag onto every declaration inside |
One entry earns a second read. LESS resolves .pill(...) by collecting every definition of that name whose argument count fits and whose guard passes, then merging their output. Four definitions of .button under four guards is ordinary LESS.
Sass allows one mixin per name, and a second @mixin pill raises no error at all. It replaces the first one, and every call from that line onward takes the new body. The ledger marks a repeated name as blocked because the repair is a design decision: collapse the set into one mixin with default values and an @if chain, then pick which branch wins when two conditions both hold.
Guards move inside the mixin
A LESS guard hangs its condition off the signature with when. Sass has no such position, so the test lands on the first line of the body as an @if and the block gains a level of indentation. The guard test functions carry no Sass names, so each one becomes a module call.
| LESS guard test | SCSS 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, write the else branch yourself |
meta and math are modules, so a file with converted guards needs @use "sass:meta"; above its first rule. The bench adds those load lines when the output reaches for them and records each one in the ledger, so a line you did not write never appears without a reason attached.
Shared names, different arithmetic
This table is where a converted file compiles cleanly and prints other numbers. Every row below survives the Sass parser.
| Call | What LESS does | What Sass does |
|---|---|---|
unit(5, px) | Attaches the unit, gives 5px | math.unit(5px) reads a unit back, giving the string "px" |
@gutter / 2 | Division | A slash separator, with division removed from the operator |
fade(@c, 25%) | Sets alpha to 0.25 | rgba($c, 0.25) |
fadeout(@c, 10%) | Lowers alpha by 0.1 | color.adjust($c, $alpha: -0.1) |
tint(@c, 10%) | Mixes toward white | mix(#fff, $c, 10%) |
greyscale(@c) | Drops saturation | grayscale($c), one letter apart |
darken(@c, 12%) | Subtracts lightness | Still resolves, now deprecated for color.adjust() |
contrast(@c, dark, light) | Picks a colour against a threshold | No equivalent, write the choice out |
data-uri("logo.svg") | Opens the file at compile time | No file access of any kind |
The first row is the sharpest one on this page. unit() exists in both languages under the same name and does opposite jobs. LESS attaches a unit to a bare number. Sass reads the unit off a number and hands back a string. A line like font-size: unit(15, px); converts to a call Sass resolves without complaint, and the declaration lands in your CSS as a quoted string instead of a size.
The order your declarations run in
LESS resolves a variable lazily, so the last declaration in a scope wins everywhere in that scope, including on the lines above itself. Sass reads top to bottom and uses the value standing at the moment of use. Same names, same file, different CSS.
@gutter: 24px;.card { padding: @gutter; }
@gutter: 32px;$gutter: 24px;.card { padding: $gutter; }
$gutter: 32px;lessc .card { padding: 32px; }
sass .card { padding: 24px; }Any theme file that redefines a token after an import leans on the LESS behaviour, and the converted file quietly takes the other value. Search your source for names declared more than once before you trust the output. The ledger flags each repeat and names the line the earlier declaration sat on.
Loading a file stops being a paste
@import in LESS pastes the file in at that spot. Sass had the same behaviour under the same keyword and is retiring it. @use loads a file once, namespaces what the file declares, and keeps private names private, so the load line is the part of a conversion with the most real work behind it.
- Partial filenames start with an underscore, and the extension leaves the load line. A file saved as
core/_reset.scssloads as@use "core/reset". - A file loads once no matter how many places ask for it. That is what the LESS
(reference)and(once)options were doing by hand. - Names arrive namespaced.
$branddeclared in_tokens.scssreads astokens.$brandelsewhere, unless the load line says@use "tokens" as *. - A name starting with a hyphen or underscore stays private to its file. LESS has no such rule, so every helper in your source is public and some of them are about to stop being.
- Load lines come first. Only comments and
@charsetsit above them, which is why the bench puts any module it needs at the very top of the output.
A conversion order that keeps the diff small
File order decides how much of the work you do twice. Convert the files nothing depends on last.
| Stage | What you convert | The failure to expect |
|---|---|---|
| 1 | Tokens and breakpoints | A stored query string losing its interpolation |
| 2 | The mixin library | Two mixins under one name, where the second wins in silence |
| 3 | Shared partials and namespaces | A helper reached through #ns > .helper() that now needs a file of its own |
| 4 | Components, one file per pass | A bare .name; call that turns out to be a class your markup uses |
| 5 | Verification | A value that moved because a token was declared twice |
Compile both trees and diff the CSS rather than reading the source. Run lessc over the old files and sass over the new ones, push both results through the same formatter, then compare. Matching CSS is the only proof the pass worked, and source review misses the two failures with the longest tail: the redeclared token and the overwritten mixin name.
What this converter leaves for you
- Nothing is evaluated.
darken(@brand, 12%)comes out asdarken($brand, 12%)with no arithmetic performed. The pass rewrites text and reports what it touched. - One file per pass. Import paths are not followed, so a mixin defined in another partial reads as undefined here. The ledger names it and suggests the load line instead of pretending the call resolves.
- Some colour names stay put. Replacing
fadeout()means choosing betweencolor.adjust()andcolor.scale(), and the two produce different colours from identical input, so the choice stays yours. - Detached rulesets are flagged, never rewritten. Turning a stored block into a mixin with
@contentchanges every call site, which is a refactor rather than a substitution. - Selector output is not compared.
@extendand:extendbuild different selector lists in edge cases, so diff the compiled CSS on any rule that uses either one. - Plugin output is invisible. Whatever a
@plugindirective introduced is unknown to this pass and to every Sass compiler.
Everything runs in this tab. The parser, the rewrite passes, and the ledger are JavaScript on the page, so an unreleased theme or a client palette under embargo stays on your machine. Load the page once, drop the network, and the bench keeps converting.
