Migrating Less 4.x → 5.x
Coming to Jess from Less 4.x? Jess is the engine behind Less 5.x, so the same breaking changes apply when you move existing Less sources onto Jess. This page lists the ones that most often need attention.
Removed: inline backtick JavaScript
Inline backtick JavaScript is removed entirely. It now reports a fatal unsupported-syntax diagnostic — not a flag you can opt back into.
// No longer valid — this reports an unsupported-syntax diagnostic:
@columns: `Math.max(12, 8) `;
In Jess there is no inline-JavaScript syntax at all. Express values with
built-in functions where possible (@columns: max(12, 8);). Script and data imports use @-use / @-from; see
Modules & imports.
Removed: IE name=value filter arguments
IE name=value filter arguments such as alpha(opacity=50) are removed, like
other IE filters. Less 5 reads alpha(opacity=50) like any other function call:
alpha is an ordinary function name and opacity=50 an ordinary =
comparison, so it gives exactly what foo(opacity=50) gives. The syntax was
only ever read by Internet Explorer up to IE8; current browsers drop the
declaration, so remove it.
Imports
Jess's module system is @-compose (namespaced import, isolated scope, optional
as alias). In .jess a bare @import is always a plain CSS
at-rule (passed through — no extension heuristic); it is never a source include.
To fold a stylesheet in and evaluate it, use an explicit dash-prefixed rule:
@-compose (preferred) or @-import (mechanical port; warns). The compiler
at-rules require the dash — bare @use/@compose are not valid Jess.
// CSS @import (passed through), NOT a source include:
@import "foo";
// Legacy source include (warns) — prefer @-compose:
@-import "./foo.less";
// The module system:
@-compose "./foo.less" as foo;
See Modules & imports.
CSS nesting is preserved by default (collapseNesting: false)
Less 5.x has first-class CSS nesting, and it keeps nested structure by default
(collapseNesting: false), where Less 4.x always flattened. Output stays nested
unless you explicitly opt into flattening.
.card {
padding: 1rem;
.title {
font-weight: 600;
}
}
By default this compiles to nested CSS (.title stays nested under .card).
Set collapseNesting: 'native' for the flattened (.card .title { … }) output
when you need wider tooling support (true is a deprecated spelling of
'native'). A flattened rule under a selector list factors the list into one
:is() (.a, #b { .c {} } → :is(.a, #b) .c), as native nesting does, where
Less 4.x wrote .a .c, #b .c.
A selector list that nesting or an extend writes never joins a selector Chromium drops
to the ones it keeps; for an extend this holds in nested output too. A parent ending with a pseudo-element, given
a user-action pseudo-class by &, gets a rule of its own, inside an at-rule bubbled out of
the rule too: .a::before, .b { &:hover { color: red } } flattens to
.a::before:hover { color: red } and .b:hover { color: red }, where Less 4 wrote one
list, .a::before:hover, .b:hover, which Chromium drops whole — .b:hover included. An
extend does the same for a selector with something after its pseudo-element:
.b:hover { color: red } .q::after:focus:extend(.b:hover) {} gives .b:hover and
.q::after:focus a rule each, and so does an all extend that places a pseudo-element
before what follows the match: .a .c:hover { color: red } .p::before:extend(.c all) {}
gives .a .c:hover and .a .p::before:hover a rule each. A list you write yourself is
left as written.
If you are porting a 4.x project that assumed flattened output, set
collapseNesting: 'native' while you migrate, then move to native nesting once your
toolchain supports it.
Less-style parent-suffix composition still works, and the parent-template model is now explicit:
&-1composes a suffix onto the parent (.col { &-1 {…} }→.col-1).&()keeps the parent selector but hoists the nested selector to root.&('')drops the parent selector entirely.&(-1)is equivalent to&-1.
At-rule variables require interpolation
Where a variable supplies syntax rather than a value, Less 5.x requires
explicit @{…} interpolation. Less 4.x still accepts the bare spelling but emits
a deprecation warning for it.
@breakpoint: (min-width: 48rem);
@animation-name: fade-in;
// Deprecated in 4.x, removed in 5.x:
// @media @breakpoint { … }
// @keyframes @animation-name { … }
// Less 5.x:
@media @{breakpoint} {
.card {
animation-name: @animation-name;
}
}
@keyframes @{animation-name} {
from { opacity: 0; }
to { opacity: 1; }
}
This applies wherever a variable becomes part of at-rule syntax — @media,
@supports, @container, @layer, @keyframes, @namespace — and it is the
same rule that already governs selectors (.@{name}), property names
(@{name}: value), and @import paths.
Variables in value positions are unaffected and keep the bare spelling. That includes ordinary declaration values and media feature values:
@sizes: {
tablet: 768px;
};
@media (min-width: @sizes[tablet]) {
.navbar {
display: inline-block;
}
}
Custom properties are written as authored
A custom property's value is CSS text. Less 5.x writes it exactly as you wrote
it and adds only @{…} interpolation, so a bare variable is no longer
replaced — Less 4.x replaced it. This also holds in a style() query value
and for a var() written inside a custom property. Write @{…} where you want
the variable's value:
@brand: #0af;
.card {
--x: @brand; // 5.x: --x: @brand; (Less 4.x wrote --x: #0af;)
--y: @{brand}; // 5.x: --y: #0af;
--z: calc(@{brand} + 1px); // 5.x: --z: calc(#0af + 1px);
color: var(--x, @brand); // 5.x: color: var(--x, #0af);
}
Because the variable is never looked up, an undefined @name in a custom
property is written as is rather than reported. Escapes (~"…"), function calls
and math in a custom property are written as they are, too.
In an ordinary declaration such as color above, var() is a CSS function: its
fallback is an argument, so a bare variable there is still replaced.
extend and the !all flag
extend behavior is validated against fixture parity, including nested and
media-scoped selectors. Per-selector all in multi-target extends is deprecated
in favor of a single !all flag:
// Deprecated:
&:extend(.a all,
.b all);
// Preferred:
&:extend(.a,
.b !all);
In every output mode, an all extend that matches part of a selector adds the
extender inside an :is() at that spot, grouped only with alternatives of the
same specificity: .a .c { color: red; } with .d:extend(.c all) {} gives
.a :is(.c, .d) { color: red; }, where Less 4.x wrote .a .c, .a .d.
Variable resolution follows source order
Less 4.x had eager-resolution edge cases where a later mixin call could appear to retroactively change an earlier declaration. Less 5.x resolves declarations in source order against the scope that exists when they are evaluated — a later side effect does not rewrite an already-evaluated sibling declaration.
@mix: blue;
.mixin() {
@mix: #989;
}
.tiny-scope {
color: @mix; // blue — resolved before .mixin() runs
.mixin();
}
Math modes
- Move
strictMathworkflows to themathoption.strictMath: trueis still accepted asmath: 'parens', with a deprecation warning. strict-legacywas removed in Less 4.0; it is still accepted and meansparens.math=alwaysstill works;parens-divisionis the default.- Under
math=always, the slash before the alpha in a CSS colour function (rgb(),hsl(),hwb(),lab(),lch(),oklab(),oklch(),color()) is a separator, never a division:rgb(0 128 255 / 50%)stays as written, where Less 4.x divided it. Converted to.jess, it stays a separator too. - The
./division operator is removed:2px ./ 2is a parse error. Write(2px / 2). - Under
math=parens(andstrict), parentheses around a function call do not reach its arguments:(foo(1 + 2))stays(foo(1 + 2)), where Less 4.x computed it (foo(3)). Write(foo((1 + 2)))to compute the argument. - Under
math=parens(andstrict), the arguments ofif(),boolean(),not(),and()andor()follow the math mode like any function argument:boolean(1 + 2 = 3)isfalse, andboolean((1 + 2 = 3))istrue. The outermost parentheses of each condition of a guard'swhen ( … )are not a paren group, and every pair inside them is: writewhen ((1 + 2 = 3))orwhen ((1 + 2) = 3)to compute.
# old
lessc --math=always styles.less styles.css
# preferred
lessc --math=parens-division styles.less styles.css
Evaluation differences (comparison, truthiness, arguments)
The changes above are structural — features that moved or were removed. This section is the smaller, more important set: input that Less 5 evaluates to something different — a different value, or a value where Less 4 stopped with an error.
The bar for changing an evaluation result is high. Less 5 follows Less 4 unless the Less 4 behaviour is defensible by history rather than by principle — meaning it does something documented or long-standing, but has no rule that explains why. Every row below states the rule Less 5 follows instead.
| expression | Less 4 | Less 5 | why |
|---|---|---|---|
1in = 2.54cm | false | true | Equal by definition. Less 4's false was a conversion-precision defect, not a language choice. |
b > a | false | true | Relational is now trichotomous. Less 4 answered false in both directions, so an author could not tell "not greater" from "never comparable". |
1px > red | false | error | No common ground. Ordering a length against a keyword is an author mistake, and Less 4 answered it meaninglessly. |
a = "a" | false | true | A value equals its own spelling. Less 4 separated text by quoting while coercing numbers — the reverse of Sass, and explicable as neither. |
(2px / 1px) | 2px | 2 | Like units cancel. A ratio of two lengths is a number; Less 4 re-attached a unit the value no longer carried. |
(1px * 2px) | 2px | calc(1px * 2px) | An area has no CSS unit. Less 4 kept the left operand's unit, which is dimensionally false. See Unit algebra below. |
(1 / 2px) | 0.5px | calc(1 / 2px) | There is no px⁻¹ in CSS. Same rule as the row above. |
url(~"b.png") with rootpath: 'r/' | url(r/b.png) | url(b.png) | An escaped string is text you wrote verbatim, so no URL option changes it; rewriting url(~"'b.png'") wrote the invalid url(r/'b.png'). Write the path into it, or use a quoted string to have it rewritten. |
url(~"'e.png'") with urlArgs: 'v=1' | url('e.png'?v=1) | url('e.png') | The same rule: urlArgs adds nothing to an escaped body, where the argument landed after the closing quote and made the URL invalid. That includes url(~"img.png"), which Less 4 wrote url(img.png?v=1); use a quoted string to have the argument added. Each such url() reports an eval/url-option-skipped warning naming it. |
when (~"true") | truthy | falsy | See Truthiness below — the one row where Less 4 promoted a string to truthy. |
not(1 > 2), and(…), or(…) | parse error | true | The logical operators are real operators in Less 5, not call-shaped syntax. |
1px + 1em | 2px | calc(1px + 1em) | Two units that do not convert have no single value; Less 4 kept the left unit. See Unit algebra below. 4 + 3px is still 7px. |
min(100% - 30px) | 70% | min(100% - 30px) | The same rule inside a math function: the browser resolves the percentage. |
max(4, 3px) | 4 | 4px | The unitless 4 was compared as 4px, as 4 + 3px is 7px, so it is written as the length it was compared as. |
percentage(~"0.5") | error | percentage(0.5) | An escaped string is text and is never read back as a number. A built-in that cannot use its argument is written out as-is. |
calc(2 * @t) with @t: (e("1px + 2px")) | calc(2 * 1px + 2px) | calc(2 * (1px + 2px)) | Text is not a computed value, so it prints as written, the parentheses around it included ((e("foo")) is (foo)). Less 4 dropped them in math and changed the arithmetic. Only an interpolation or a function reading the text gets it without them. |
@media print { @import "t"; } @import "t"; | t inside @media print only | t there and at the root | An import is skipped only after a copy of the same file in the same place: under the same selectors and at-rule preludes, where its output would be the same (a mixin call imports where it is called, and a bare & ruleset where its parent does). Less 4 skipped it after a copy anywhere, so the root never got the styles — or the mixins — the author imported there. A file that imports itself, directly or through another file, still stops there. |
Truthiness is unchanged — with one exception
In Less, nothing is truthy except the boolean true (or an expression that evaluates to it, like 1 > 0). That is Less 4's rule and Less 5 keeps it. A guard takes a condition; anything else needs an explicit comparison.
Note this is not the Jess / Sass+ rule, where a non-empty string is truthy. Do not carry that intuition into .less.
| guard | Less 4 | Less 5 |
|---|---|---|
when (true), when (1 > 0) | truthy | truthy |
when (1), when (0), when (1px) | falsy | falsy |
when ("a"), when (""), when (~"a") | falsy | falsy |
when (red), when (abc) | falsy | falsy |
when (~"true") | truthy | falsy |
Only the last row moves, and it moves toward the rule. ~"true" is a string, and strings are not truthy in Less — ~"a" is falsy in both versions. Less 4 made an exception for this one string because it re-parsed the escaped bytes back through evaluation and handed the guard the keyword true. Its contents decided the outcome, which is exactly what the rule says must not happen. Less 5 removes the exception.
If you were relying on when (~"true"), write when (true).
Unit algebra: a unit CSS cannot spell changes the spelling, not the arithmetic
Multiplying or dividing two united operands can produce a unit CSS has no name
for — 1px * 2px is an area, 1 / 2px is a reciprocal length. Less 4 invented a
unit for these, keeping the left operand's (1px * 2px → 2px). That answer
looks plausible and is dimensionally false.
Less 5 keeps the number and the units, and only changes how the result is
written: an unexpressible result is composed as calc(…), in the operand order
you wrote. The arithmetic always happens. That is what lets a chain recover
the moment a later operation cancels back to a real unit:
| expression | Less 4 | Less 5 |
|---|---|---|
(2px / 1px) | 2px | 2 |
(1px * 2px) | 2px | calc(1px * 2px) |
(1 / 2px) | 0.5px | calc(1 / 2px) |
(10% * 1px) | 10% | calc(10% * 1px) |
(1px * 1px / 1px) | 1px | 1px |
(8cats * 9dogs / 4cats) | 18dogs | 18dogs |
unit((1px * 4em / 2cm)) | 2 | 2 |
The last three rows are the point: they are unchanged. 1px * 1px has no CSS
spelling, but it is still a computed value carrying 1 and the units px·px, so
dividing by 1px cancels back to an honest 1px. Only a result that is
emitted while still unexpressible gets the calc(…) spelling.
An unexpressible result also warns (eval/unexpressible-unit), and the
unitMode option decides what happens to it:
unitMode | (1 / 2px) | |
|---|---|---|
loose | 0.5px | Less 4's answer, plus a warning |
preserve (default) | calc(1 / 2px) | plus a warning |
strict | rejected | set by the strict preset |
No mode is silent. Silent preservation is the worst outcome — the stylesheet looks fine and you never learn the expression was meaningless.
The table is the .less default. A .jess entry file defaults to strict, so
$(1 / 2px) and $(1px + 3em) are eval/invalid-unit-arithmetic errors unless
you set unitMode. A unitless number still takes the other operand's unit
($(1 + 2px) is 3px) in every mode.
The canonical statement of this rule is §4.7 of
docs/design/RESOLVED-SEMANTICS-AND-NAMING.md in the repository.
A guard selects; it does not assert
The 1px > red row above is about a value: if((1px > red), a, b) has no
answer, so it errors. In a guard the same comparison does not error — the definition
simply does not match, and overload resolution moves on:
.generic(@a, @b) when (@a < @b) { r: less; }
.generic(@a, @b) when (@a > @b) { r: greater; }
.generic(@a, @b) { r: fallback; }
a { .generic(1, true); } // r: fallback — neither guard matches, nothing errors
This is unchanged from Less 4, which answers r: fallback here as well. It
is called out because the value-position rows above might otherwise suggest a
guard would now fail the compile. It does not: a guard asks does this definition
apply?, and "these operands share no ground" is a perfectly good "no".
This is not a mode the evaluator switches on. The two positions are lowered to
two different nodes when the file is parsed — a comparison in a value, a match
in a guard — over one shared ground computation and one shared answer table, so
the two positions cannot drift apart. See §4.2a of
docs/design/RESOLVED-SEMANTICS-AND-NAMING.md.
Unaffected, and worth knowing
An explicit comparison is never affected by the truthiness change:
.m() when (@name = "") { … } // identical in Less 4 and 5
.m() when (@name) { … } // truthiness — see the table
Numeric comparison, unit conversion between compatible units, and colour equality all behave as before. @import of a .css file, url(…) imports and (inline) are unchanged.
Deprecated CLI / option paths
relativeUrls: trueis a deprecated spelling ofrewriteUrls: 'all', with a warning; an explicitrewriteUrlswins.lesscno longer accepts--relative-urls: use--rewrite-urls=all.ieCompatis accepted and ignored, with adeprecation/ie-compat-optionwarning, andlesscno longer accepts--ie-compat.insecureis ignored, andlesscno longer accepts--insecure: remote imports are https-only and always verify the certificate.strictImports/--strict-importsis removed: it is still accepted, has no effect, and reports adeprecation/strict-imports-optionwarning. Every@importis processed where it is written, including one inside a selector block, which Less 4 silently dropped under this option.dumpLineNumbers/--line-numbers→ accepted with a warning and otherwise ignored; use sourcemaps.- built-in
compressis not deprecated: it is a supported minifier in 5.x and replacesless-plugin-clean-css.
Runs on the Jess engine
Jess is the engine. Less 5.x is Jess with the Less-compatibility surface turned on, so the evaluation model described throughout these docs is exactly what your migrated Less sources run against.