Two indented syntaxes, one sigil apart
Of the four preprocessor syntaxes, indented SASS and Stylus are the two with no braces and no semicolons. Nesting is carried by whitespace in both. So a conversion between them never has to rebuild structure the way SASS to SCSS does. What remains is a series of small decisions about single characters: the $ on a variable, the = and + on a mixin, the @ on an if, the % on a placeholder, the #{} around an interpolation. Most SASS to Stylus converters make those decisions silently. This one puts the one contested decision on a switch, lists every rewrite in a ledger, and marks the lines where the meaning shifted even though the syntax came out clean.
Why the dollar sign gets its own switch
Stylus treats $ as an ordinary identifier character. $brand = #5b2a86 compiles, and color: $brand resolves. The Stylus documentation and most published .styl files drop the sign, so brand = #5b2a86 is the idiom you will see in a codebase already written in Stylus. Both are correct output. They differ in one failure mode.
$white: #fdfcfa
$radius: 6px !default
.card
background: $white
border-radius: $radius
color: white
white = #fdfcfa
radius ?= 6px
.card
background: white
border-radius: radius
color: white // now #fdfcfa, not the keyword
Stylus resolves a bare identifier as a variable before falling back to the CSS keyword of the same name. Once white is assigned, every later white in the file reads the variable. The same trap exists for black, transparent, none, auto, bold, center, and the named colours. The converter checks each dropped name against a list of those keywords and adds an amber mark to the line, so you decide whether to rename the variable or flip the switch to keep the sign. Keep the sign when the SASS file names its colour tokens after colours, or when you want $ to stay greppable across a mixed codebase. Drop the sign when the file is joining a Stylus project whose style guide already reads bare.
!default becomes the conditional assignment ?=, which assigns only when the name is still undefined. !global has no Stylus form. The flag is removed and the line is marked, because a Stylus assignment inside a mixin or rule stays scoped to the block around the line.
The plus sign changes sides
In SASS the sigils on mixins are fixed: =card defines, +card includes. In Stylus a mixin is a function whose body contains selectors or declarations, so both the definition and the call are written as card(). The + survives in one place only. A Stylus mixin whose body contains {block}, the counterpart of @content, has to be called with a leading plus: +respond(48rem) followed by an indented block. The converter pre-scans every mixin definition for @content, records which names are block mixins, and writes the plus on those calls. A call followed by an indented body is treated as a block call even when the definition lives in another file.
=card($pad: 16px)
padding: $pad
=respond($bp)
@media (min-width: $bp)
@content
.panel
+card(20px)
+respond(48rem)
width: 100%
card(pad = 16px)
padding: pad
respond(bp)
@media (min-width: bp)
{block}
.panel
card(20px)
+respond(48rem)
width: 100%
Default arguments switch from $pad: 16px to pad = 16px. Keyword arguments at the call site, +card($pad: 20px), come out as card(pad: 20px), which Stylus accepts as named arguments. @include name using ($x) is left as SASS with a red mark, since Stylus passes nothing into a block.
Control flow loses the at-sign and keeps the words
Stylus control flow reads like a scripting language. @if becomes if, @else if becomes else if, @while becomes while. Comparison operators, and, or and not are the same words in both. Loops change more:
@each $name, $size in $sizesbecomesfor name, size in sizes. Iterating a hash in Stylus yields key and value in the same order, so two-variable loops over maps carry over without reordering.@for $i from 1 through 4becomesfor i in (1..4). The two-dot range includes the last value, matchingthrough.@for $i from 1 to 4becomesfor i in (1...4). Three dots exclude the last value, matchingto. Getting this one dot wrong produces one column too many or too few, so the ledger counts range rewrites on their own line.@function step($n)with@returnbecomesstep(n)with a barereturn. Stylus does not distinguish functions from mixins at definition time. A body ending inreturnis a function, a body containing declarations is a mixin.
Division needs parentheses in Stylus
Sass has spent years steering people from $a / 2 to math.div($a, 2). Stylus has the opposite rule. A bare slash in a property value is a literal slash, because font: 14px/1.5 Inter has to survive. Division happens inside parentheses or inside a function call's argument list. The converter turns math.div(15px, 16px) into (15px / 16px), wraps a bare $columns / 8 in parentheses with an amber mark, and leaves percentage($i / 4) alone because the call parentheses already make the slash divide. A 14px/1.5 with no variable on either side is never touched.
Construct by construct
| SASS | Stylus | Note |
|---|---|---|
$brand: #5b2a86 | brand = #5b2a86 | The switch decides whether $ stays. |
$radius: 6px !default | radius ?= 6px | Conditional assignment. |
=card($pad: 16px) | card(pad = 16px) | Defaults use =. |
+card(20px) | card(20px) | Plain call, no sigil. |
+respond(48rem) with a body | +respond(48rem) | Block call keeps the plus. |
@content | {block} | |
%muted, @extend %muted | $muted, @extend $muted | Placeholders keep $ on both settings. |
.gap-#{$name} | .gap-{name} | |
#{&} | {selector()} | Marked. |
$sizes: (sm: 4px, md: 8px) | sizes = {sm: 4px, md: 8px} | One-line maps only. |
map-get($sizes, md) | sizes[md] | Nested keys chain brackets. |
map-has-key($sizes, md) | (md in sizes) | |
nth($steps, 2) | steps[1] | Stylus indexes from zero. Marked when the index is a variable. |
if($dark, #000, #fff) | (dark ? #000 : #fff) | |
transparentize($c, .2) | fade-out(c, 20%) | Alpha scaled to a percentage. Marked. |
adjust-hue($c, 30) | spin(c, 30) | |
@debug $m | p(m) | @warn and @error become warn() and error(). |
@use "sass:math" | commented out | The functions are Stylus built-ins. |
@use "tokens" as t | @import 'tokens' | t. is stripped from every member below. Marked. |
Nested properties get flattened
Indented SASS allows a property to open a block: font: on one line, family: and size: indented below, compiling to font-family and font-size. Stylus has no nested property form. The converter runs an early pass joining the parent and child names with a hyphen and moving each child up to the parent's indent. A parent line carrying its own value, font: 15px with children beneath, keeps the value as its own declaration and then emits the children.
What comes back marked
Two colours appear in the output gutter and in the list under the panes. Clicking any entry in the list moves the cursor to the line.
- Amber, read again. The line converted, and the result compiles, but a meaning shifted on the way. A zero-based index, a fade amount scaled from 0.2 to 20%, a
!globalremoved, a@useturned into an@importwith its namespace gone, a variable name shadowing a CSS keyword, a custom property whose value Stylus passes through with little evaluation. - Red, left as SASS. The line has no Stylus spelling and was copied through untouched:
@forward,@at-root,@use ... with (...),@contentwith arguments,@include ... using, and calls into thestring,selectorandmetamodules. Leaving the SASS in place means the Stylus compiler fails on the exact line, which beats compiling something quietly different.
Where this converter stops
- Indented SASS only. Braces are not parsed. For
.scssinput use the SCSS to Stylus converter, which handles the brace-to-indent rebuild. - Nothing is compiled here. The output is text. Paste the result into Stylus to CSS, compile the original with SASS to CSS, and diff the two before the file goes into a build.
- Hash lookups are written with bare keys,
sizes[md]. If a key fails to resolve after compiling, quote the key:sizes['md']. - Colour functions beyond
lighten,darken,saturate,desaturate,mix,rgba,adjust-hue,transparentizeandopacifyare passed through by name.color.adjustandcolor.scalehave no one-to-one Stylus call and are not rewritten. @import 'partial'keeps its path. The partial itself still needs converting, one file at a time through this page.- Files over a few thousand lines re-run on every keystroke with a short delay. Paste, wait for the ledger to settle, then copy.
Should this file become Stylus at all?
Two reasons hold up. The file is joining a project already written in Stylus, or the build already runs the Stylus compiler and adding Dart Sass to the toolchain is the thing nobody wants. Outside those two cases, moving from indented SASS to Stylus trades an active compiler for a slow one. Dart Sass ships releases most months. Stylus releases arrive a few times a year, and the module system, math.div, @forward and @use ... with have no counterpart, which is why they come back red above.
If the reason for leaving .sass is thin editor support for the indented syntax, the nearer move is SASS to SCSS. Same compiler, same features, every formatter and linter works, and the change is punctuation only.
When Stylus is the right call, convert one partial at a time, compile both sides, and read the amber list before the red one. The red lines fail loudly. The amber lines are the ones a diff will not catch.