Settings Panel

General

The basic information about your store, shown to your customers.

Your store is available at https://acme-outdoor.shop.

Notifications

Choose which emails you get. Security emails are always sent.

An email for every order, with the items and the shipping address.

When a product has fewer than 5 items left.

Sales, visitors and top products, every Monday morning.

Danger zone

These actions can’t be undone.

Close the store

Delete the store, its products and its order history permanently.

Close store

@use 'sprucecss/scss/spruce' as *;

.settings-panel {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');

  &__section {
    display: grid;
    gap: spacer('s') spacer('l');
    padding: spacer-clamp('m', 'l');

    @include breakpoint('md') {
      grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
    }

    & + & {
      border-block-start: config('border-width-base', $display) solid color('border');
    }
  }

  &__intro {
    @include layout-stack('xxs');
  }

  &__title {
    font-size: font-size('h5');
    margin: 0;
  }

  &__description {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

  &__fields {
    max-inline-size: 36rem;
    @include layout-stack('m');
  }

  &__switch {
    @include layout-stack('xxs');

    .form-description {
      margin: 0;
      padding-inline-start: 2.75rem;
    }
  }

  &__section--danger &__title {
    color: color('danger', 'alert');
  }

  &__danger-row {
    align-items: center;
    border: config('border-width-base', $display) solid color-alpha('danger', 0.4, 'alert');
    border-radius: radius('md');
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s');
    justify-content: space-between;
    padding: spacer('s');
  }

  &__danger-text {
    @include layout-stack('xxs');
  }

  &__danger-title {
    color: color('heading');
    font-weight: 700;
  }

  &__footer {
    align-items: center;
    background-color: color('surface');
    border-block-start: config('border-width-base', $display) solid color('border');
    border-end-end-radius: inherit;
    border-end-start-radius: inherit;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s');
    inset-block-end: 0;
    justify-content: flex-end;
    padding: spacer('s') spacer-clamp('m', 'l');
    position: sticky;
    @include transition($property: 'background-color, box-shadow');

    &--dirty {
      background-color: color-tint('surface', 6%, $with: color('primary'));
      box-shadow: shadow('lg');
    }
  }

  &__status {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    margin: 0 auto 0 0;

    .settings-panel__footer--dirty & {
      color: color('heading');
      font-weight: 700;
    }
  }

  &__buttons {
    @include layout-cluster('xs');
  }
}

[dir='rtl'] .settings-panel__status {
  margin: 0 0 0 auto;
}
<form class="settings-panel" action="#" method="post" data-settings-panel>
    <section class="settings-panel__section" aria-labelledby="settings-general">
        <div class="settings-panel__intro">
            <h3 class="settings-panel__title" id="settings-general">General</h3>
            <p class="settings-panel__description">The basic information about your store, shown to your customers.</p>
        </div>
        <div class="settings-panel__fields">
            <div class="form-group">
                <label class="form-label form-label--required" for="settings-name">Store name</label>
                <input class="form-control" id="settings-name" name="name" type="text" value="Acme Outdoor" autocomplete="organization" required>
            </div>
            <div class="form-group">
                <label class="form-label" for="settings-subdomain">Store address</label>
                <div class="form-group--stacked">
                    <span class="form-group-label" aria-hidden="true">https://</span>
                    <input class="form-control" id="settings-subdomain" name="subdomain" type="text" value="acme-outdoor" aria-describedby="settings-subdomain-help" spellcheck="false">
                    <span class="form-group-label" aria-hidden="true">.shop</span>
                </div>
                <p class="form-description" id="settings-subdomain-help">Your store is available at https://acme-outdoor.shop.</p>
            </div>
            <div class="form-group">
                <label class="form-label" for="settings-timezone">Time zone</label>
                <select class="form-control" id="settings-timezone" name="timezone">
                    <option value="Europe/Budapest" selected>(GMT+02:00) Budapest</option>
                    <option value="Europe/London">(GMT+01:00) London</option>
                    <option value="America/New_York">(GMT−04:00) New York</option>
                    <option value="America/Los_Angeles">(GMT−07:00) Los Angeles</option>
                </select>
            </div>
        </div>
    </section>
    <section class="settings-panel__section" aria-labelledby="settings-notifications">
        <div class="settings-panel__intro">
            <h3 class="settings-panel__title" id="settings-notifications">Notifications</h3>
            <p class="settings-panel__description">Choose which emails you get. Security emails are always sent.</p>
        </div>
        <div class="settings-panel__fields">
            <div class="settings-panel__switch">
                <label class="form-switch">
                    <input class="form-switch__control" type="checkbox" role="switch" name="notify_orders" aria-describedby="settings-orders-help" checked>
                    <span class="form-label form-switch__label">New orders</span>
                </label>
                <p class="form-description" id="settings-orders-help">An email for every order, with the items and the shipping address.</p>
            </div>
            <div class="settings-panel__switch">
                <label class="form-switch">
                    <input class="form-switch__control" type="checkbox" role="switch" name="notify_stock" aria-describedby="settings-stock-help" checked>
                    <span class="form-label form-switch__label">Low stock</span>
                </label>
                <p class="form-description" id="settings-stock-help">When a product has fewer than 5 items left.</p>
            </div>
            <div class="settings-panel__switch">
                <label class="form-switch">
                    <input class="form-switch__control" type="checkbox" role="switch" name="notify_digest" aria-describedby="settings-digest-help">
                    <span class="form-label form-switch__label">Weekly summary</span>
                </label>
                <p class="form-description" id="settings-digest-help">Sales, visitors and top products, every Monday morning.</p>
            </div>
        </div>
    </section>
    <section class="settings-panel__section settings-panel__section--danger" aria-labelledby="settings-danger">
        <div class="settings-panel__intro">
            <h3 class="settings-panel__title" id="settings-danger">Danger zone</h3>
            <p class="settings-panel__description">These actions can’t be undone.</p>
        </div>
        <div class="settings-panel__fields">
            <div class="settings-panel__danger-row">
                <div class="settings-panel__danger-text">
                    <p class="settings-panel__danger-title">Close the store</p>
                    <p class="form-description">Delete the store, its products and its order history permanently.</p>
                </div>
                <a class="btn btn--outline-danger btn--sm" href="#">Close store</a>
            </div>
        </div>
    </section>
    <footer class="settings-panel__footer" data-settings-footer>
        <p class="settings-panel__status" role="status" data-settings-status></p>
        <div class="settings-panel__buttons">
            <button class="btn btn--ghost-secondary" type="reset" data-settings-discard>Discard</button>
            <button class="btn btn--primary" type="submit">Save changes</button>
        </div>
    </footer>
</form>
(() => {
  document.querySelectorAll('[data-settings-panel]').forEach((form) => {
    const footer = form.querySelector('[data-settings-footer]');
    const status = form.querySelector('[data-settings-status]');
    const buttons = [...footer.querySelectorAll('button')];
    const serialize = () => new URLSearchParams(new FormData(form)).toString();
    let saved = serialize();
    let dirty = false;

    const update = () => {
      dirty = serialize() !== saved;
      footer.classList.toggle('settings-panel__footer--dirty', dirty);
      buttons.forEach((button) => { button.disabled = !dirty; });

      if (dirty) {
        status.textContent = 'You have unsaved changes';
      } else if (status.textContent === 'You have unsaved changes') {
        status.textContent = '';
      }
    };

    form.addEventListener('input', update);
    form.addEventListener('change', update);
    // The reset event fires before the fields are reset.
    form.addEventListener('reset', () => setTimeout(update));

    form.addEventListener('submit', (event) => {
      if (!form.checkValidity()) {
        return;
      }

      // Save with your own request in a listener of this event, then the new state becomes the baseline.
      event.preventDefault();
      form.dispatchEvent(new CustomEvent('settings:save', { detail: { data: new FormData(form) } }));
      saved = serialize();
      update();
      status.textContent = 'Your changes have been saved';
    });

    // Warn before leaving the page with unsaved changes.
    window.addEventListener('beforeunload', (event) => {
      if (dirty) {
        event.preventDefault();
      }
    });

    update();
  });
})();

Settings Panel

A settings form split into labelled sections, with a sticky footer that tracks unsaved changes, and a danger zone for destructive actions.

Anatomy

  • .settings-panel__section: a section with the __intro (title and description) on the side and the __fields next to it. Below the md breakpoint, they stack.
  • .settings-panel__switch: a Spruce .form-switch with a description under it.
  • .settings-panel__section--danger: the destructive actions, visually separated.
  • .settings-panel__footer: the sticky footer with a status message, the "Discard" (type="reset") and "Save changes" buttons.

Accessibility and Usability

  • Every section is labelled by its heading, so screen reader users can jump between them.
  • The descriptions are connected to their fields with aria-describedby; the switches use role="switch" and are announced as on or off.
  • The footer status is a role="status" live region: "You have unsaved changes" and "Your changes have been saved" are announced without moving the focus.
  • The buttons are disabled while there is nothing to save, and the browser asks for confirmation before leaving the page with unsaved changes.
  • The store address prefix and suffix (https://, .shop) are hidden from screen readers; the description under the field says the full address.

Technical Details

  • The script compares the serialized FormData with the last saved state, so changing a field back clears the "unsaved" state.
  • On submit, the script prevents the page reload and dispatches a settings:save event with the FormData. Send it in a listener:
form.addEventListener('settings:save', (event) => {
  fetch('/settings', { method: 'POST', body: event.detail.data });
});
  • Without JavaScript, the form submits normally and the buttons are always enabled.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.