Your first page
You are going to build an account settings page: a nav bar with a working theme switch, a rail layout, a profile form, choice cards, switches, a confirm dialog and a save bar that sticks to the bottom of the screen. It is about 130 lines of HTML, you will type all of them, and at the end it is a real page rather than a demo with the interesting parts left out.
Before you start
You need Deck installed and a folder you can serve. Make a
file called settings.html beside your assets folder. Every
step below adds to that one file, in order, and each one ends with something you can
look at.
Nothing here needs a server language, a bundler or a framework. If you are following along in PHP, Rails or anything else, the markup is the same — only the file extension changes.
1. The shell
Start with the same head you finished the install with, and an empty
<main>. Two classes are doing the work.
.container centres the content and applies the page gutter, which is
fluid — it tightens on a phone and opens up on a monitor without a media query
deciding where the line falls. .section applies the vertical rhythm the
same way.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Account settings — Ledgerly</title>
<link rel="stylesheet" href="assets/deck/deck.css">
<script src="assets/deck/deck.js" defer></script>
<script src="assets/deck/deck-extras.js" defer></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<main id="main" class="container section">
<div class="stack stack-2 mb-8">
<h1>Account settings</h1>
<p class="lede">Changes are saved to your account and apply everywhere you are signed in.</p>
</div>
</main>
</body>
</html>
.skip-link is off-screen until it takes focus, and then it is the first
thing a keyboard user reaches. It costs one line and it is the difference between a
page someone can navigate and one they have to tab through forty times. Put it in
first so you never have to remember to add it.
.stack-2 on the wrapper sets the gap between the heading and the lede.
Notice there is no margin on either of them. That is the whole layout idea in Deck
and it is worth reading the layout guide once you
have finished here.
2. A nav bar that already does something
Above the <main>, add a header. .sticky-top pins it
with a translucent backdrop and the right stacking token, so you do not pick a
z-index. .push on the button takes the leftover space,
which is how it lands on the far end of the row.
<header class="sticky-top">
<div class="container">
<nav class="navbar" aria-label="Main">
<a class="navbar-brand" href="#">
<svg class="icon icon-lg" style="color:var(--brand)">
<use href="assets/deck/deck-icons.svg#grid"></use>
</svg>
Ledgerly
</a>
<button class="btn btn-icon btn-ghost push" data-deck-theme aria-label="Switch theme">
<svg class="icon"><use href="assets/deck/deck-icons.svg#moon"></use></svg>
</button>
</nav>
</div>
</header>
<nav class="navbar" aria-label="Example">
<a class="navbar-brand" href="#step2">
<svg class="icon icon-lg" style="color:var(--brand)"><use href="../../assets/deck/deck-icons.svg#grid"></use></svg>
Ledgerly
</a>
<button class="btn btn-icon btn-ghost push" data-deck-theme aria-label="Switch theme">
<svg class="icon"><use href="../../assets/deck/deck-icons.svg#moon"></use></svg>
</button>
</nav>
<nav class="navbar" aria-label="Example">
<a class="navbar-brand" href="#step2">
<svg class="icon icon-lg" style="color:var(--brand)"><use href="../../assets/deck/deck-icons.svg#grid"></use></svg>
Ledgerly
</a>
<button class="btn btn-icon btn-ghost push" data-deck-theme aria-label="Switch theme">
<svg class="icon"><use href="../../assets/deck/deck-icons.svg#moon"></use></svg>
</button>
</nav>
Reload and press the moon. The page switches between light and dark and remembers
your choice on the next load. You did not write a line of JavaScript:
data-deck-theme is one of
fifteen attributes that wire a
behaviour with no code.
3. The rail layout
Settings pages are a narrow rail of section links beside a wide column of controls.
Inside <main>, after the heading block, add this.
<div class="split split-rail-start">
<aside>
<nav class="panel sidebar" aria-label="Settings sections">
<span class="sidebar-group">Settings</span>
<a class="sidebar-link" href="#profile" aria-current="page"><span>Profile</span></a>
<a class="sidebar-link" href="#preferences"><span>Preferences</span></a>
<a class="sidebar-link" href="#danger"><span>Danger zone</span></a>
</nav>
</aside>
<form class="stack stack-8" id="settings">
<!-- the next three steps go here -->
</form>
</div>
.split is a single column by default and becomes two at 48rem;
.split-rail-start makes the first of those two a fixed rail rather than
an equal half. So the phone layout is the one you get for free and the desktop
layout is the variant — which is the right way round, because the phone is the harder
case and the one people forget to check.
The rail is 20rem and you change it with --rail, not by
editing Deck:
<div class="split split-rail-start" style="--rail: 14rem">
<nav class="panel sidebar" aria-label="Example sections" style="max-inline-size:18rem">
<span class="sidebar-group">Settings</span>
<a class="sidebar-link" href="#step3" aria-current="page"><span>Profile</span></a>
<a class="sidebar-link" href="#step4"><span>Preferences</span></a>
<a class="sidebar-link" href="#step5"><span>Danger zone</span></a>
</nav>
<nav class="panel sidebar" aria-label="Example sections" style="max-inline-size:18rem">
<span class="sidebar-group">Settings</span>
<a class="sidebar-link" href="#step3" aria-current="page"><span>Profile</span></a>
<a class="sidebar-link" href="#step4"><span>Preferences</span></a>
<a class="sidebar-link" href="#step5"><span>Danger zone</span></a>
</nav>
4. The profile fields
Inside the <form>. This is the part worth slowing down on, because
it is the part every application has and the part most frameworks make you assemble
yourself.
<section class="stack stack-4" id="profile">
<h2>Profile</h2>
<div class="cluster">
<span class="avatar avatar-xl">AC</span>
<div class="stack stack-2">
<button class="btn btn-sm" type="button">Upload a photo</button>
<p class="help">JPG or PNG, up to 2 MB.</p>
</div>
</div>
<div class="field-row">
<div class="field">
<label class="label" for="name">Full name</label>
<input class="input" id="name" name="name" value="Ada Chen" required>
</div>
<div class="field">
<label class="label" for="email">Email</label>
<input class="input" id="email" name="email" type="email" value="ada@ledgerly.io" required>
<p class="help">Invoices and receipts go here.</p>
</div>
</div>
<div class="field">
<label class="label" for="bio">Bio <span class="optional">optional</span></label>
<textarea class="textarea" id="bio" name="bio" rows="3">Finance lead.</textarea>
</div>
</section>
JPG or PNG, up to 2 MB.
Invoices and receipts go here.
<div class="stack stack-4" style="max-inline-size:34rem">
<div class="cluster">
<span class="avatar avatar-xl">AC</span>
<div class="stack stack-2">
<button class="btn btn-sm" type="button">Upload a photo</button>
<p class="help">JPG or PNG, up to 2 MB.</p>
</div>
</div>
<div class="field-row">
<div class="field">
<label class="label" for="t-name">Full name</label>
<input class="input" id="t-name" value="Ada Chen">
</div>
<div class="field">
<label class="label" for="t-email">Email</label>
<input class="input" id="t-email" type="email" value="ada@ledgerly.io">
<p class="help">Invoices and receipts go here.</p>
</div>
</div>
</div>
<div class="stack stack-4" style="max-inline-size:34rem">
<div class="cluster">
<span class="avatar avatar-xl">AC</span>
<div class="stack stack-2">
<button class="btn btn-sm" type="button">Upload a photo</button>
<p class="help">JPG or PNG, up to 2 MB.</p>
</div>
</div>
<div class="field-row">
<div class="field">
<label class="label" for="t-name">Full name</label>
<input class="input" id="t-name" value="Ada Chen">
</div>
<div class="field">
<label class="label" for="t-email">Email</label>
<input class="input" id="t-email" type="email" value="ada@ledgerly.io">
<p class="help">Invoices and receipts go here.</p>
</div>
</div>
</div>
Three things you did not have to do. .field is a stack, so the gap
between the label, the control and the help text comes from the container rather than
from margins on each piece. .field-row is one column on a phone and two
from 40rem, so the responsive behaviour is in the class rather than in a media query
you write. And the for and id pairs are what make the label
clickable and the control announced — Deck styles them, it cannot supply them.
required on the input is the real HTML attribute, not a class. Deck
styles :user-invalid, so the field turns red after someone has actually
interacted with it and left it wrong — not while they are still typing. See the
forms guide for why that distinction matters.
5. Preferences: choice cards and switches
Still inside the form, after the profile section. An <hr> between
sections is enough of a divider; you do not need a card around each one.
<hr>
<section class="stack stack-4" id="preferences">
<h2>Preferences</h2>
<div class="field">
<label class="label" for="tz">Time zone</label>
<select class="select" id="tz" name="tz">
<option>Europe/London</option>
<option selected>Europe/Berlin</option>
<option>America/New_York</option>
</select>
</div>
<fieldset class="fieldset">
<legend class="label">Invoice reminders</legend>
<div class="grid grid-tight">
<label class="check check-card">
<input type="radio" name="remind" value="off">
<span class="check-text">
<span>Never</span>
<span class="check-note">No reminders at all.</span>
</span>
</label>
<label class="check check-card">
<input type="radio" name="remind" value="due" checked>
<span class="check-text">
<span>On the due date</span>
<span class="check-note">One email, the morning it is due.</span>
</span>
</label>
<label class="check check-card">
<input type="radio" name="remind" value="chase">
<span class="check-text">
<span>Until it is paid</span>
<span class="check-note">Every three days after the due date.</span>
</span>
</label>
</div>
</fieldset>
<div class="stack stack-2">
<label class="switch">
<input type="checkbox" name="digest" checked>
<span>Weekly summary email</span>
</label>
<label class="switch">
<input type="checkbox" name="twofa">
<span>Require two-factor authentication</span>
</label>
</div>
</section>
<div class="stack stack-4">
<div class="grid grid-tight">
<label class="check check-card">
<input type="radio" name="t-remind" value="off">
<span class="check-text"><span>Never</span><span class="check-note">No reminders at all.</span></span>
</label>
<label class="check check-card">
<input type="radio" name="t-remind" value="due" checked>
<span class="check-text"><span>On the due date</span><span class="check-note">One email, the morning it is due.</span></span>
</label>
</div>
<label class="switch"><input type="checkbox" checked><span>Weekly summary email</span></label>
</div>
<div class="stack stack-4">
<div class="grid grid-tight">
<label class="check check-card">
<input type="radio" name="t-remind" value="off">
<span class="check-text"><span>Never</span><span class="check-note">No reminders at all.</span></span>
</label>
<label class="check check-card">
<input type="radio" name="t-remind" value="due" checked>
<span class="check-text"><span>On the due date</span><span class="check-note">One email, the morning it is due.</span></span>
</label>
</div>
<label class="switch"><input type="checkbox" checked><span>Weekly summary email</span></label>
</div>
Both of these are one real form control wearing a costume. The choice card is a
<label> around a radio, and the card lights up through
:has(input:checked) — no state class, no JavaScript, and the value posts
with the form because it is a radio. The switch is the same trick around a checkbox.
Keyboard, screen readers, form serialisation and the browser's own validation all
work because nothing was reimplemented.
6. The danger zone and a confirm dialog
The last section inside the form, then the dialog itself just before
</body>.
<hr>
<section class="stack stack-4" id="danger">
<h2>Danger zone</h2>
<div class="alert alert-bad">
<svg class="icon"><use href="assets/deck/deck-icons.svg#alert-triangle"></use></svg>
<div>
<div class="alert-title">Closing the account is permanent</div>
<p class="alert-body">Seven years of invoices, every export and the audit
log go with it. We cannot restore them afterwards.</p>
</div>
</div>
<div>
<button class="btn btn-outline btn-danger" type="button"
onclick="document.getElementById('close-account').showModal()">
Close this account
</button>
</div>
</section>
<dialog class="modal" id="close-account">
<form method="dialog" class="stack stack-0">
<div class="modal-header">
<h3 class="modal-title">Close this account?</h3>
</div>
<div class="modal-body stack stack-3">
<p>This deletes 2,481 invoices and cannot be undone.</p>
<div class="field">
<label class="label" for="confirm">Type <code>ledgerly</code> to confirm</label>
<input class="input" id="confirm" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button class="btn" value="cancel">Keep my account</button>
<button class="btn btn-danger" value="close">Close account</button>
</div>
</form>
</dialog>
That is a real <dialog>. The browser gives you the focus trap, the
backdrop, dismissal on Esc, the top layer so no
z-index can ever cover it, and method="dialog" closes it on
submit and tells you which button was pressed. Deck styles it. None of that behaviour
is Deck's, which is exactly why it is reliable.
The confirm-by-typing field is not decoration either. A destructive action with a single Are you sure? gets clicked through; one that asks you to type the name makes you read what you are about to lose.
7. The save bar
The last thing in the form, after the danger section.
<div class="form-actions form-actions-sticky">
<button class="btn btn-primary" type="submit">Save changes</button>
<button class="btn btn-ghost" type="reset">Discard</button>
</div>
.form-actions-sticky pins the row to the bottom of the viewport with a
blur behind it, and pads itself past the home indicator on a phone. On a settings page
of any length that is the difference between a save button people find and one they
scroll past.
Now make it do something. Add an onsubmit to the form and a script before
</body>:
<form class="stack stack-8" id="settings" onsubmit="save(event)">
<script>
function save(event) {
event.preventDefault();
const t = Deck.toast({ kind: 'loading', title: 'Saving…', duration: 0 });
setTimeout(() => t.update({ kind: 'good', title: 'Settings saved', duration: 4000 }), 900);
}
</script>
Deck.toast() returns a handle, and duration: 0 means it
stays until something changes it. So one toast becomes the spinner and then the
result, instead of a spinner toast plus a success toast stacking up. Swap the
setTimeout for your real fetch and the shape is the same:
async function save(event) {
event.preventDefault();
const t = Deck.toast({ kind: 'loading', title: 'Saving…', duration: 0 });
try {
const res = await fetch('/api/settings', { method: 'POST', body: new FormData(event.target) });
if (!res.ok) throw new Error(await res.text());
t.update({ kind: 'good', title: 'Settings saved', duration: 4000 });
} catch (err) {
t.update({ kind: 'bad', title: 'Could not save', text: err.message, duration: 0 });
}
}
Note the failure branch uses duration: 0. An error that dismisses itself
after five seconds is an error nobody read.
Check it
Reload and go through this list. Every one of these should already work, and if one does not it is worth finding out why now rather than later.
- Press Tab from the address bar. The first stop is Skip to content.
- Press the moon in the nav bar. The page switches theme and remembers it.
- Narrow the window to phone width. The rail moves above the form and the two-column field row becomes one.
- Click the middle of a choice card, not the radio. It selects.
- Clear the email field, type
notanemail, then tab away. It turns red. - Press Close this account. The dialog opens centred, Esc closes it.
- Press Save changes. One toast appears, spins, and turns green.
Seven behaviours, and the only JavaScript you wrote was the six-line
save function.
What that taught you
| Idea | Where it showed up | Read next |
|---|---|---|
| Containers own the spacing | Every .stack-*, and not one margin |
Stop writing margins |
| One real control, styled | The switch, the choice cards, the dialog | Forms that validate themselves |
| Behaviour from attributes | data-deck-theme |
JavaScript API |
| Dark mode for free | It already worked when you pressed the moon | Add a dark mode switch |
| Mobile first, then the variant | .split, .field-row |
Split |
The obvious next change is to make it yours. Open the page, put
style="--hue-brand: 265" on the <html> tag, and reload:
every fill, border, focus ring and shadow on the page moves to purple together. That
is the theming guide, and it is the thing Deck is
actually for.