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>
Press the moon — it works here too, because it is the same attribute
<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">
The rail. aria-current marks the section you are on
<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>
field-row is one column on a phone and two side by side from 40rem
AC

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>
Click anywhere on a card. The whole thing is the label
<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

The ideas this page used and where they are explained
IdeaWhere it showed upRead 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.