sass
The sass package on npm is Dart Sass, the reference implementation of the Sass language, compiled from Dart to JavaScript. It gives you two things: a sass command that turns .scss or .sass files into CSS, and a JavaScript library with compile() and compileString() so build tools can call it directly. The language adds what CSS historically lacked at authoring time, namely variables, mixins, functions, loops over maps and lists, and a module system where @use pulls in another file exactly once under a namespace. Since LibSass was retired this is the only implementation still developed, so the language and this compiler advance together. Two details matter when you install it. This package is Dart compiled to JS, roughly 680 kB gzipped on disk and slower than the native binary that the sibling sass-embedded package ships. And the language is mid-migration: @import and the global color functions have been formally deprecated since 1.80.0 and are slated to become errors in Dart Sass 2.0.
Dart Sass is well-run, thoroughly documented, and the only real implementation of the language, so if you already write Sass this is simply the answer, and you should probably be installing sass-embedded for the speed. For a new project, check whether native CSS plus Lightning CSS already covers you before taking on a preprocessor with a deprecation migration in front of it.
Use it if
- You maintain an existing Sass codebase. Nothing else compiles .scss correctly, LibSass is retired, and this is the implementation everything else is measured against
- You generate CSS from data at build time: @each over a map of spacing tokens, @for over a breakpoint list, @function doing unit math. Native CSS still has no way to do that, and PostCSS plugins that approximate it are more machinery, not less
- You consume a Sass-based design system such as Bootstrap, Bulma, or Foundation, which ships .scss partials and expects you to override variables before compiling rather than after
- You want compile-time module boundaries: @use loads a file once and namespaces its members, and @forward lets a folder present one public entry point, which is a real improvement over the global namespace @import gave you
- You need programmatic compilation with custom importers or custom Sass functions written in JavaScript, which the modern compile API supports directly
- Compile time is visible in your workflow. This package is Dart translated to JavaScript; sass-embedded ships the actual Dart executable and speaks the embedded protocol to it, exposing the identical modern API. On a large stylesheet the difference is the whole reason that package exists, and switching is usually one line in package.json
- You are starting a new project in 2026. Nesting, custom properties, @layer, color-mix(), and container queries are in browsers now, and Lightning CSS will down-level what is still too new. That covers most of what people historically reached for Sass to do, without adding a language and a compiler
- You are on Tailwind v4. Its engine is CSS-first with @theme and @utility, and stacking Sass underneath means two compilers, confusing @apply resolution, and a build order you have to keep in your head. The Tailwind team's guidance has been to drop the preprocessor
- You cannot budget the @import migration. It has been deprecated since 1.80.0 and is going away in 2.0. Converting is not a find and replace: variables become module-scoped, a partial that quietly relied on a global leaking in now fails, configuration must happen at the single point of first load, and the global color functions move into sass:color. sass-migrator does most of it and then leaves you the interesting parts
- Quiet builds matter to you. Existing, working stylesheets start printing deprecation warnings as new ones ship, and there have been several in the 1.9x and 1.10x line alone, including adjacent compound selectors and the old if() syntax. Keeping logs readable means maintaining a silenceDeprecations list, and that list is a to-do list you now own
- You want a leaf dependency on a modest Node baseline. Current releases require Node 20.19.0 or newer and pull chokidar, immutable, and source-map-js
Setup reality
npm install -D sass gives you both the CLI and the library, and Vite, webpack, and Parcel all pick it up automatically for .scss files once it is present. Node 20.19.0 or newer is required by current releases, and three dependencies come along: chokidar for watch mode, immutable, and source-map-js. Under Vite you should set css.preprocessorOptions.scss.api to 'modern-compiler', because the default path uses the legacy render API and prints a deprecation notice on every single build. In your own code, use compile() and compileString() and not render() or renderSync(), which are the deprecated legacy API with their own migration page; also note the README's warning that compileAsync is substantially slower than compile, since the Dart-to-JS build cannot block on async work, so only reach for it when an importer genuinely needs to await something. The JS API does not write source maps or append the sourceMappingURL comment for you the way the CLI does; result.sourceMap is a plain object you serialize yourself. Expect deprecation warnings on day one if the codebase uses @import or darken(), and plan on a --silence-deprecation list keyed by the deprecation IDs printed in the warnings. Running in a browser is supported but needs an import map for the immutable dependency and, in esbuild, the --keep-names flag. And if builds feel slow, remember the package to reach for is sass-embedded, not a different loader.
Patterns
One file, a whole directory, and watch modecompile-from-the-cli
npx sass src/styles.scss dist/styles.css
npx sass src/:dist/ # every non-partial in the tree
npx sass --watch src/:dist/ --style=compressed
npx sass --load-path=node_modules src/styles.scss dist/styles.css
npx sass --no-source-map --quiet-deps src/:dist/In directory mode, files whose names start with an underscore are partials and are not compiled on their own, only loaded by others. --style takes exactly two values now, expanded and compressed; the old nested and compact options are gone. --quiet-deps hides warnings coming from files under load paths, which is how you stop a dependency's deprecated @import statements from filling your terminal.
compile and compileString from Nodeuse-the-modern-js-api
import * as sass from 'sass';
const result = sass.compile('src/styles.scss', {
style: 'compressed',
loadPaths: ['src/styles', 'node_modules'],
sourceMap: true,
});
result.css; // string
result.sourceMap; // plain object, or undefined
result.loadedUrls; // every file touched, for watch invalidation
const inline = sass.compileString('.box { width: 10px + 15px; }');
const slow = await sass.compileAsync('src/styles.scss');render() and renderSync() are the legacy API and are deprecated with their own documentation page; everything new should use these four functions. The README is explicit that compileAsync is substantially slower than compile, so only use it when an importer or custom function must await. loadedUrls is the list you feed to your own file watcher so an edit to a partial rebuilds the entry that pulled it in.
Load a partial once, under a namespaceuse-instead-of-import
// _variables.scss
$brand: #0869b8;
$radius: 4px;
// main.scss
@use 'variables'; // namespace defaults to the basename
@use 'sass:color';
@use './layout/grid' as g;
.btn {
background: variables.$brand;
border-radius: variables.$radius;
color: color.scale(variables.$brand, $lightness: 80%);
}@use loads a file exactly once regardless of how many partials reference it, and its members stay behind a namespace, so two files can each define $size without a collision. That namespacing is also the migration pain: under @import everything was global, so a partial that used $brand without saying where it came from compiles today and stops compiling the moment you convert. Every @use must appear before any other rule in the file.
Override a dependency's defaults at load timeconfigure-a-module
// _theme.scss
$radius: 4px !default;
$brand: #0869b8 !default;
// main.scss (the ONE place theme is configured)
@use 'theme' with ($radius: 8px, $brand: #c60200);
// components/_button.scss
@use '../theme'; // plain load, picks up the configuration
.btn { border-radius: theme.$radius; }Only variables declared with !default can be configured, a module can be configured exactly once per compilation, and the configuring @use must run before any plain load of the same module. That is why configuration belongs in the entry file. The old Bootstrap pattern of assigning a variable then importing the library no longer applies: assignment before @use has no effect at all.
Give a directory one public entry pointforward-a-folder
// components/_index.scss
@forward 'button';
@forward 'card' hide $card-internal-padding, card-private-mixin;
@forward 'grid' as grid-*;
@forward 'theme' with ($radius: 8px !default);
// main.scss
@use 'components';
.x { @include components.button-reset(); }@forward re-exports another module's members without making them visible in the forwarding file itself, which is how you build a public surface for a folder. A file named _index.scss is loaded automatically when you @use the directory, so @use 'components' finds it. hide and show control what leaks out, and the as prefix-* form avoids name collisions between folders.
Load Sass out of node_modules without hardcoded pathsresolve-dependency-stylesheets
import * as sass from 'sass';
sass.compile('src/main.scss', {
loadPaths: ['src/styles'],
importers: [new sass.NodePackageImporter()],
});With the package importer registered you write @use 'pkg:bootstrap/scss/bootstrap' and resolution follows the dependency's package.json exports, sass, and style fields the way Node does, instead of you guessing at a relative path into node_modules. Be aware that 1.101.0 changed it to honor import-only variants declared in those fields and the changelog labels that a potentially breaking bug fix.
Silence what you have not fixed, lock in what you havemanage-deprecation-warnings
npx sass --silence-deprecation=import,global-builtin src/:dist/
npx sass --fatal-deprecation=color-functions src/:dist/
npx sass --future-deprecation=import src/:dist/Every warning prints a deprecation ID, and that ID is exactly what these flags take. The workflow that keeps a big codebase moving is to silence the categories you have not started on and mark the finished ones fatal so they cannot come back in a pull request. The JS API has the same options as silenceDeprecations, fatalDeprecations, and futureDeprecations arrays. --fatal-deprecation also accepts a version, meaning everything deprecated as of that release.
Get maps out of the CLI and out of the APIemit-source-maps
npx sass --source-map --embed-sources src/main.scss dist/main.css
// the JS API writes nothing for you
import * as sass from 'sass';
import { writeFileSync } from 'node:fs';
const r = sass.compile('src/main.scss', { sourceMap: true, sourceMapIncludeSources: true });
writeFileSync('dist/main.css', r.css + '\n/*# sourceMappingURL=main.css.map */');
writeFileSync('dist/main.css.map', JSON.stringify(r.sourceMap));The CLI writes the .map file and appends the comment; the JS API does neither, and r.sourceMap is a plain object you have to serialize and reference yourself. Note also that 1.101.1 made stack trace and source URLs always absolute or relative to the working directory, so anything that parsed the old load-path-relative URLs needs rechecking.
Mixins, functions, and loops, the actual reason to preprocessgenerate-css-from-data
@use 'sass:math';
@mixin truncate($lines: 1) {
@if $lines == 1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
@function rem($px, $base: 16px) {
@return math.div($px, $base) * 1rem;
}
$space: (sm: 4px, md: 8px, lg: 16px);
@each $name, $size in $space {
.gap-#{$name} { gap: $size; }
.p-#{$name} { padding: $size; }
}
.title { @include truncate(2); font-size: rem(24px); }Use math.div rather than a slash: division by / was removed, and a bare slash inside a value is now read as CSS separator syntax, which silently produces something like font: 12px/1.5 instead of a number. Interpolation in a selector, #{$name}, means the class name never appears literally in the source, so a content scanner or dead-CSS tool will not see it.
Move off darken() and friendsreplace-legacy-color-functions
@use 'sass:color';
$brand: #0869b8;
.a { background: color.adjust($brand, $lightness: -10%); }
.b { background: color.scale($brand, $lightness: -20%); }
.c { background: color.mix($brand, white, 25%); }
.d { background: color.change($brand, $alpha: 0.5); }
// deprecated globals: darken($brand, 10%), lighten(), saturate(), transparentize()The global color functions were deprecated in the same release as @import, and the replacements live in sass:color. They are not drop-in: color.adjust shifts a channel by a fixed amount and clips at the limit, while color.scale moves it proportionally toward the limit, which is usually what you actually wanted from darken(). 1.101.4 also changed serialization so legacy colors with a non-integer channel emit percentages rather than decimals in rgb().
Use the modern compiler and swap in the native binarywire-up-vite
// vite.config.js
export default {
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
loadPaths: ['src/styles'],
silenceDeprecations: ['import'],
},
},
},
};
// then, for speed
// npm i -D sass-embeddedWithout api: 'modern-compiler' the default path goes through the legacy render API and prints a deprecation notice on every build, which people usually misdiagnose as a problem with their own stylesheets. sass-embedded exposes the same modern API backed by the native Dart binary and Vite will use it when it is installed; the tradeoffs are platform-specific binaries in the lockfile and no browser build.
Convert @import and legacy division automaticallyrun-the-migrator
npm i -g sass-migrator
sass-migrator module --migrate-deps src/main.scss
sass-migrator division --migrate-deps 'src/**/*.scss'
sass-migrator --help # other migrators, including a dry-run flagThe module migrator rewrites @import into @use and @forward and rewrites every member reference with its new namespace; --migrate-deps follows the load graph instead of touching a single file. It handles the bulk and then leaves the interesting cases: configured variables, partials that relied on a global leaking in, and anything assembled through interpolation. Run it on a branch, compile before and after, and diff the emitted CSS rather than the Sass.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| sass-embedded | npm | You want the same language and the same modern JS API but backed by the native Dart binary instead of the JS translation, which is the faster default for any real build. |
| lightningcss | npm | You have moved to native CSS nesting and custom properties and only need transpiling, minification, and vendor prefixing from a browser target. |
| postcss | npm | You want plugins operating on standard CSS rather than a separate authoring language, and you are happy assembling the behavior you need. |
| less | npm | You are maintaining an existing Less codebase, most likely an older Bootstrap or Ant Design theme, and porting it is not worth the churn. |