After lesson 2.1.4, the Kladde’s stylesheet has just under 280 lines, and it will grow with every page. In a single file, you lose the
overview, and some things appear twice: The width 48rem shows up again in every media query, and rules for the navigation repeat
.site-nav in every selector. SCSS solves that. It’s CSS with a few extensions that are compiled to ordinary CSS before serving,
because browsers only understand CSS.
In this lesson, the prototype moves into its first real web project, src/Kladde.Web. It serves the pages and compiles the SCSS on
every dotnet build. Afterward, the Kladde looks exactly as before but is built better. From module 2.3 on, the web API goes into the
same project, and from module 2.5 on the interface in Blazor.
What SCSS is
Sass is a language for stylesheets, SCSS its notation with curly braces and semicolons that looks like CSS. Every valid CSS file is already valid SCSS. So you can start with your existing stylesheet and use the extensions step by step. The four most important ones:
- Variables that are substituted at compile time,
- nesting of rules,
- partials: partial files that are put together into one stylesheet with
@use, - mixins: named building blocks of declarations that you insert in several places.
Compiling is done with Dart Sass, the implementation that the Sass project itself maintains. In a .NET project, the NuGet package
AspNetCore.SassCompiler (MIT license, not from Microsoft) brings it along, as a ready-made program for Windows, Linux and macOS.
You don’t need Node.js for that. The package hooks into dotnet build and dotnet publish: First it compiles, then it builds.
The generated CSS does not belong in the repository. It’s created anew from the SCSS on every build. If it were in Git, sooner or later somebody would change it by hand, and the change would be gone at the next build.
Variables: Sass or custom property?
A Sass variable begins with $:
$wide: 48rem;
@media (min-width: $wide) {
h1 {
font-size: 2.5rem;
}
}
At compile time, Dart Sass substitutes the value; the finished CSS says @media (min-width: 48rem). The variable itself no longer
exists in the browser.
That’s the difference to the custom properties from lesson 2.1.4 (--color-text): They exist at runtime in the browser, they’re
inherited, and you can reassign them in a media query. The dark scheme is based on that. A simple rule follows:
- Colors, spacing, fonts and everything that can change at runtime stay custom properties. The dark scheme only works that way.
- You use Sass variables for what custom properties can’t do. The most important example: A media query can’t read a custom
property.
@media (min-width: var(--wide))has no effect. The width of the wide layout therefore becomes a Sass variable.
Nesting, in moderation
In SCSS, rules may stand inside rules. The outer selector is put in front of the inner ones:
.site-nav {
ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
a {
display: inline-block;
padding: var(--space-2) var(--space-1);
&[aria-current="page"] {
font-weight: 700;
text-decoration-thickness: 3px;
}
}
}
Dart Sass turns that into:
.site-nav ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.site-nav a {
display: inline-block;
padding: var(--space-2) var(--space-1);
}
.site-nav a[aria-current=page] {
font-weight: 700;
text-decoration-thickness: 3px;
}
The & stands for the selector around it. So &[aria-current="page"] means “this a, if it also has the attribute”, with no space
in between. Without the &, it would say .site-nav a [aria-current=page], with a space: an element with the attribute inside a
link, and there’s no such thing. You write &:hover or &:focus-visible the same way. That Dart Sass leaves out the quotation marks
around page is valid CSS and changes nothing.
Nest in moderation:
- At most three levels. Every level lengthens the selector and raises its specificity. Deeply nested SCSS produces exactly the overlong, hard-to-override selectors that lesson 2.1.4 wanted to avoid.
- Don’t mirror the HTML.
main { section { div { p { … } } } }ties the stylesheet to the exact structure of the page; one extradivin the HTML, and nothing applies anymore. Nest inside a building block (.site-nav,.card,.field), not across the page.
Partials and @use
A file whose name begins with an underscore is a partial: a part that isn’t compiled on its own but included in another stylesheet. The Kladde gets five partials and one file that puts them together:
src/Kladde.Web/Styles/
├── kladde.scss puts everything together; becomes wwwroot/css/kladde.css
├── _tokens.scss custom properties, dark scheme, the width $wide, the mixin wide
├── _base.scss box-sizing, font, headings, links, focus, images
├── _layout.scss header, navigation, main content, footer, cards
├── _tables.scss tables
└── _forms.scss fields, groups, hints, errors, buttons
// kladde.scss: puts the Kladde's stylesheet together from its parts
@use "tokens";
@use "base";
@use "layout";
@use "tables";
@use "forms";
@use "tokens" loads _tokens.scss; you leave out the underscore and the extension. What the partial offers in variables and mixins,
you reach through its name as a namespace: tokens.$wide, tokens.wide. Every partial that needs something from another one
writes its own @use at its start. Dart Sass still loads each partial only once, and its CSS appears only once in the result, at the
place where it was loaded first. That’s why @use "tokens" comes first in kladde.scss: That puts the custom properties at the top
of the finished stylesheet.
Comments with // are notes for the SCSS and are missing from the finished CSS. Comments with /* … */ are carried over by Dart Sass
into the verbose output.
In older guides, you’ll find @import instead of @use. @import is deprecated: It has no namespaces, loads files several times,
and Dart Sass reports it with a warning because it goes away in the next major version. Always use @use.
Mixins: the media query in one place
A mixin is a named building block that you insert with @include. With @content, it takes in a whole block. That fits the wide
layout perfectly:
// in _tokens.scss
$wide: 48rem;
@mixin wide {
@media (min-width: $wide) {
@content;
}
}
// in _base.scss
@use "tokens";
h1 {
font-size: 2rem;
margin: 0 0 var(--space-3);
@include tokens.wide {
font-size: 2.5rem;
}
}
h1 {
font-size: 2rem;
margin: 0 0 var(--space-3);
}
@media (min-width: 48rem) {
h1 {
font-size: 2.5rem;
}
}
The rule for the wide layout now stands right next to the base rule, no longer in a block at the end of the file. Whoever changes h1
sees both at a glance. And if the wide layout should ever begin at 50rem, you change one line in _tokens.scss.
Mixins can also have parameters and contain declarations. Use them sparingly: A mixin copies its declarations to every place where
it’s inserted. If two rules share the same values, a selector list like .field-hint, .field-error is often the simpler solution.
The web project Kladde.Web
Until now, the pages lived in prototype/ and needed the form server from your practice folder. Now the Kladde gets a web project of
its own. In the Kladde’s folder:
dotnet new web -o src/Kladde.Web
dotnet sln add src/Kladde.Web/Kladde.Web.csproj
dotnet new web creates the emptiest of all web projects: a project file with Microsoft.NET.Sdk.Web, a Program.cs with a “Hello
World!” endpoint, appsettings.json and Properties/launchSettings.json with the ports for dotnet run. You adapt two things to the
Kladde’s house rules:
- In
Kladde.Web.csproj, delete the line<Nullable>enable</Nullable>. This rule applies to all projects inDirectory.Build.props; since the Tools module in K1, no project sets it itself. - Replace the content of
Program.cs:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseDefaultFiles();
app.UseStaticFiles();
app.Run();
You know this from the form server: UseStaticFiles serves the files, and UseDefaultFiles shows the index.html for /. Without a
folder given, both use the folder wwwroot in the project. In module 2.3, you’ll learn what these lines do exactly and in which order
they have to stand.
The pages and the logo move with git mv, so that Git recognizes them as moved and keeps their history:
mkdir src/Kladde.Web/wwwroot
git mv prototype/index.html prototype/contacts.html prototype/appointment.html prototype/appointment-error.html prototype/logo.svg src/Kladde.Web/wwwroot/
Start the Kladde:
dotnet run --project src/Kladde.Web
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5238
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /home/ada/kladde/src/Kladde.Web
dotnet new chose the port at random; you’ll see a different number, and the path is a different one too. Open the address in the
browser.
SCSS in the build
Add the package:
dotnet add src/Kladde.Web package AspNetCore.SassCompiler --version 1.107.1
Without any further setting, the package compiles on every build all SCSS files from the project’s Styles folder to wwwroot/css,
except the partials. Styles/kladde.scss becomes wwwroot/css/kladde.css, and the pages already include exactly that file. In
addition, kladde.css.map is created, a source map: With it, the DevTools show in Styles not kladde.css:96 but the SCSS file
and line a rule comes from, such as _layout.scss:30.
You write the settings into a file sasscompiler.json in the project folder. For the Kladde, this is enough:
{
"Source": "Styles",
"Target": "wwwroot/css",
"Configurations": {
"Release": {
"Arguments": "--style=compressed --no-source-map"
}
}
}
Source and Target repeat the defaults so that everybody sees them. Under Configurations is what applies only to a build with
-c Release: Then Dart Sass writes the CSS without spaces and line breaks and without a source map, the way it’s served later. With
the ordinary dotnet build (Debug), it stays easy to read.
Finally, the folder with the generated CSS belongs in the Kladde’s .gitignore:
# Kladde: generated by AspNetCore.SassCompiler in the build
src/Kladde.Web/wwwroot/css/
After a build, git check-ignore -v src/Kladde.Web/wwwroot/css/kladde.css shows which line excludes the file.
After every change to the SCSS, you build again: dotnet build, or you stop dotnet run with Ctrl+C and start it again, because
dotnet run builds before it starts. Then reload the page. If the browser still holds the old file in its cache, “Disable cache” from
lesson 2.1.5 helps.
When the SCSS is wrong
An error in the SCSS makes the build fail, like an error in the C# code. If, for example, the semicolon after width: 100% is missing
in _tables.scss, dotnet build reports (shortened here; on your machine, the path of the package’s .targets file stands in front
of every line):
error : Error running sass compiler: Error: expected ";".
error : ╷
error : 5 │ border-collapse: collapse;
error : │ ^
error : ╵
error : Styles\_tables.scss 5:18 @use
error : Styles\kladde.scss 5:1 root stylesheet
Build failed with 1 error(s) in 2.3s
Read it like a compiler message from K0: file _tables.scss, line 5, character 18, a ; was expected. Dart Sass noticed the error
only in the line after the missing semicolon, because until there the declaration could have gone on. The last line shows the way:
kladde.scss loaded _tables.scss in line 5.
A second typical error: A partial uses tokens.wide but has no @use "tokens" of its own:
error : Error running sass compiler: Error: There is no module with namespace "tokens".
That kladde.scss has already loaded the tokens doesn’t help: Every partial sees only what it loads itself with @use. That way, it
stays visible in every file what it depends on.
Exercise 1: Kladde.Web serves the prototype
On a new branch kladde-web, create the project src/Kladde.Web as described above: template, solution, the Nullable line gone,
Program.cs with static files, the four pages and the logo moved to wwwroot with git mv. For now, you leave the stylesheet in
prototype/css/.
Start the Kladde with dotnet run --project src/Kladde.Web and check:
- All four pages appear, and the links between them work.
- The pages are unstyled. In the Network tab,
kladde.csshas which status code, and why? dotnet buildends without a warning, and the check scriptcheck.ps1from K1 (format and build) ends with exit code 0.
Solution 1
- The pages appear at the address from the output of
dotnet run, the home page at/. kladde.csscomes with 404: The pages ask forcss/kladde.css, that is,wwwroot/css/kladde.css, and that doesn’t exist yet. The Console reports it too. This very file is what the build will generate in a moment.- If
check.ps1reports something aboutProgram.cs, it’s usually the line ending of the last line: The.editorconfigfrom K1 determines whether a file ends with a line break, and your editor may have done it differently.dotnet format Kladde.slnxtidies that up; afterward,check.ps1ends with 0.
The project file then looks like this (the package’s version is added in exercise 2):
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Exercise 2: The stylesheet in partials
Move the stylesheet with git mv prototype/css/kladde.css src/Kladde.Web/Styles/kladde.scss and split it into the five partials
from the section “Partials and @use”. In doing so:
- The width
48rembecomes the Sass variable$widein_tokens.scss, plus the mixinwide. - The “Wider screens” block at the end goes away: Each of its rules moves with
@include tokens.wideto its base rule. - The rules of
.site-nav,.site-footer,.card,.field,.choiceand.error-summaryare nested, at most three levels deep. kladde.scsscontains only the five@usenow.
Add the package, create sasscompiler.json and extend the .gitignore. Delete the empty folder prototype.
The exercise is checked when all four points are met:
dotnet buildends without a warning and createssrc/Kladde.Web/wwwroot/css/kladde.css.git statusdoesn’t show this CSS.- In the Network tab,
kladde.csscomes with 200. - Every page looks exactly as before. Compare your screenshots from lesson 2.1.4 with new ones at 360 and 1280 pixels, light and dark, page by page. If something differs, a rule landed in the wrong place while nesting: Thanks to the source map, the DevTools show which partial the rule comes from.
Solution 2
_tokens.scss:
// _tokens.scss: all design values in one place
// Media queries can't read custom properties, which is why the width is a Sass variable.
$wide: 48rem;
:root {
color-scheme: light dark;
--color-text: #1d2422;
--color-muted: #4a5552;
--color-surface: #fbfaf7;
--color-surface-raised: #ffffff;
--color-border: #c9cfcc;
--color-accent: #1f5f5b;
--color-on-accent: #ffffff;
--color-link: #1a5c8a;
--color-focus: #b35c00;
--color-danger: #a4262c;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--radius: 0.375rem;
--font-body: system-ui, "Segoe UI", Roboto, sans-serif;
--measure: 68ch;
}
@media (prefers-color-scheme: dark) {
:root {
--color-text: #e6ebe9;
--color-muted: #b4bfbb;
--color-surface: #141a19;
--color-surface-raised: #1d2524;
--color-border: #3d4a47;
--color-accent: #7cc4bd;
--color-on-accent: #0d1413;
--color-link: #8cc4f0;
--color-focus: #ffb366;
--color-danger: #ff8a8f;
}
}
// Rules that apply only from the wide layout on.
@mixin wide {
@media (min-width: $wide) {
@content;
}
}
_base.scss:
// _base.scss: the basics for all pages
@use "tokens";
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.5;
color: var(--color-text);
background: var(--color-surface);
}
h1,
h2 {
line-height: 1.2;
}
h1 {
font-size: 2rem;
margin: 0 0 var(--space-3);
@include tokens.wide {
font-size: 2.5rem;
}
}
h2 {
font-size: 1.375rem;
margin: var(--space-4) 0 var(--space-2);
}
p {
max-width: var(--measure);
}
a {
color: var(--color-link);
}
:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
img {
max-width: 100%;
height: auto;
}
_layout.scss:
// _layout.scss: header, navigation, main content, footer and cards
@use "tokens";
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-4);
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
@include tokens.wide {
justify-content: space-between;
padding-inline: var(--space-4);
}
}
.site-nav {
ul {
display: flex;
flex-wrap: wrap;
gap: var(--space-1) var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
a {
display: inline-block;
padding: var(--space-2) var(--space-1);
&[aria-current="page"] {
font-weight: 700;
text-decoration-thickness: 3px;
}
}
}
main {
max-width: 60rem;
margin: 0 auto;
padding: var(--space-4) var(--space-3) var(--space-5);
}
.site-footer {
padding: var(--space-3);
border-top: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 0.875rem;
p {
margin: 0;
}
}
.cards {
display: grid;
gap: var(--space-3);
@include tokens.wide {
grid-template-columns: repeat(2, 1fr);
}
}
.card {
padding: var(--space-3);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius);
h2 {
margin-top: 0;
}
}
_tables.scss:
// _tables.scss: tables like the contact list
table {
width: 100%;
border-collapse: collapse;
}
caption {
padding-bottom: var(--space-2);
font-weight: 700;
text-align: start;
}
th,
td {
padding: var(--space-2);
border-bottom: 1px solid var(--color-border);
text-align: start;
vertical-align: top;
}
thead th {
border-bottom-width: 2px;
}
td a {
overflow-wrap: anywhere;
}
_forms.scss:
// _forms.scss: fields, groups, hints, errors and buttons
fieldset {
margin: 0 0 var(--space-4);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
legend {
padding: 0 var(--space-1);
font-weight: 700;
}
.field {
margin-bottom: var(--space-3);
label {
display: block;
margin-bottom: var(--space-1);
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
max-width: 30rem;
padding: var(--space-2);
font: inherit;
color: inherit;
background: var(--color-surface-raised);
border: 1px solid var(--color-muted);
border-radius: var(--radius);
}
[aria-invalid="true"] {
border: 2px solid var(--color-danger);
}
}
.choice {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
input {
width: 1.25rem;
height: 1.25rem;
margin: 0;
accent-color: var(--color-accent);
}
}
.field-hint,
.field-error {
margin: var(--space-1) 0 0;
font-size: 0.875rem;
}
.field-hint {
color: var(--color-muted);
}
.field-error {
color: var(--color-danger);
font-weight: 700;
}
.error-summary {
margin-bottom: var(--space-4);
padding: var(--space-3);
border: 3px solid var(--color-danger);
border-radius: var(--radius);
h2 {
margin-top: 0;
}
}
button {
padding: var(--space-2) var(--space-4);
font: inherit;
font-weight: 700;
color: var(--color-on-accent);
background: var(--color-accent);
border: 0;
border-radius: var(--radius);
cursor: pointer;
}
kladde.scss contains the five @use from the section “Partials and @use”. The project file now has the entry
<PackageReference Include="AspNetCore.SassCompiler" Version="1.107.1" />.
After dotnet build, the files kladde.css and kladde.css.map are in wwwroot/css. The generated CSS isn’t character for
character the same as your old one: The comments with // are missing, and the rules of the wide layout now stand at their base
rules instead of at the end. For the browser, that’s equivalent, because every media query rule still comes after its base rule and
has the same specificity. For this lesson, all four pages were compared at 360 and 1280 pixels, light and dark, with both stylesheets,
by the computed values of every single element: No value differed.
Two errors are common when splitting:
- The
h1is too small on the screen. The rule from the old “Wider screens” block got lost instead of moving to the base rule. - A spacing is wrong now. A rule like
.site-footer planded in another building block while nesting and is now called something like.card .site-footer p. The DevTools show in Styles that the rule doesn’t apply, and through the source map where it comes from.
Exercise 3: Making the build fail
Introduce two errors one after the other, and undo each before you introduce the next:
- In
_tables.scss, delete the semicolon afterwidth: 100%. - In
_tables.scss, insert into the rule fortablean@include tokens.wide { font-size: 1.125rem; }, without a@useat the start of the file.
Build each time with dotnet build and read the exit code ($LASTEXITCODE in PowerShell, $? in bash). Write down file, line and
cause from the message. Fix the second error last in such a way that the font of the tables really gets larger on wide screens, and
then take it out again: The Kladde doesn’t need it.
Solution 3
- The build fails with exit code 1 and
Error: expected ";"atStyles\_tables.scss 5:18. Line 5 is reported, the line withborder-collapse, because only there does Dart Sass notice that the previous declaration hadn’t ended. - The build fails with exit code 1 and
There is no module with namespace "tokens". The message shows the lines of the@include. The fix is a line@use "tokens";at the start of_tables.scss. After that, the build ends with 0, and in the generated CSS there’s a@media (min-width: 48rem) { table { font-size: 1.125rem; } }after the rule fortable.
In both cases, no new CSS was created. So the error shows up in the build, not only when a person opens the page. And because the build runs in every pull request, a broken stylesheet doesn’t even reach the main branch.
Exercise 4: Debug and Release
Build the Kladde once with dotnet build and once with dotnet build -c Release. After each build, compare the size of
wwwroot/css/kladde.css and look into the file. What changed, and is the source map still there?
Solution 4
With the stylesheet from solution 2:
Debug kladde.css 4624 bytes, easy to read, plus kladde.css.map
Release kladde.css 3791 bytes, one single long line, without kladde.css.map
Your numbers differ if your stylesheet is different. Without spaces and line breaks, the file is about a fifth smaller. For a single file of this size, that’s little; how much compression saves in addition during transfer, you’ll measure in lesson 2.1.8. A Release build doesn’t create the source map anew. An old one from an earlier Debug build stays around as long as you don’t empty the folder, though; in any case, the served file in Release no longer points to it.
The Kladde: Kladde.Web with SCSS in the build
Before the commit, check with git status what’s added: Kladde.slnx, .gitignore, in the project Kladde.Web.csproj,
Program.cs, appsettings.json, appsettings.Development.json, Properties/launchSettings.json, sasscompiler.json, the six files
in Styles and the moved files in wwwroot, plus the disappearance of prototype/. bin/, obj/ and wwwroot/css/ must not be
among them. Run check.ps1, then commit with a message like “Serve the prototype from Kladde.Web and build its SCSS” and open a pull
request with the screenshots before and after.
The booking form now submits to /book in Kladde.Web, and there’s no endpoint for it yet: The answer is 404. You no longer need the
form server for the Kladde from now on. In module 2.5, the booking gets its real page and its real endpoint.
What you can do now
- You explain what SCSS is and why the generated CSS doesn’t belong in the repository.
- You use custom properties for everything that applies at runtime and Sass variables for what custom properties can’t do, such as the width in a media query.
- You nest inside a building block, at most three levels deep, and set
&correctly. - You split a stylesheet into partials with
@useand bundle media queries in a mixin. - You create an empty web project that serves
wwwroot, and have SCSS compiled on everydotnet build. - You read an error message from Dart Sass and find the file and line.
Read more
- Static files in ASP.NET Core: how ASP.NET Core serves files
from
wwwroot, also withMapStaticAssets, which additionally compresses files when publishing. - Tutorial: Create a minimal API with ASP.NET Core: Microsoft
Learn’s introduction to a web project like
Kladde.Web; that’s the topic of module 2.3. - Sass documentation:
@use, variables, nesting and mixins in detail. - AspNetCore.SassCompiler: the package with all the settings in
sasscompiler.json.