Settings

This pattern shows how to build a settings component that is responsive, supports multiple device inputs, and works across browsers.

Full article · Video on YouTube · Source on Github

<main>
  <h1>Settings</h1>
  
  <form>

    <section>
      <header>
        <h2>Sound & vibration</h2>
        <small>Adjust system volume channels</small>
      </header>
      
      <fieldset>
        
        <div class="fieldset-item">
          <picture aria-hidden="true">
            <svg viewBox="0 0 24 24">
              <title>A note icon</title>
              <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
            </svg>
          </picture>
          <div class="input-stack">
            <label 
              for="media-volume" 
              id="media-volume" 
              aria-hidden="true">
                Media volume
            </label>
            <input 
              name="media-volume" 
              aria-labelledby="media-volume" 
              type="range" 
              value="3" 
              max="10" 
              style="--track-fill: 30%"
            >
          </div>
        </div>

        <div class="fieldset-item">
          <picture aria-hidden="true">
            <title>A phone icon</title>
            <svg viewBox="0 0 24 24">
              <path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/>
            </svg>
          </picture>
          <div class="input-stack">
            <label for="call-volume" id="call-volume" aria-hidden="true">Call volume</label>
            <input 
              name="call-volume" 
              aria-labelledby="call-volume" 
              type="range" 
              value="7" 
              max="10" 
              style="--track-fill: 70%"
            >
          </div>
        </div>

        <div class="fieldset-item">
          <picture aria-hidden="true">
            <svg viewBox="0 0 24 24">
              <title>A bell icon</title>
              <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
            </svg>
          </picture>
          <div class="input-stack">
            <label for="ring-volume" id="ring-volume" aria-hidden="true">Ring volume</label>
            <input 
              name="ring-volume" 
              aria-labelledby="ring-volume" 
              type="range" 
              value="5" 
              max="10" 
              style="--track-fill: 50%"
            >
          </div>
        </div>

        <div class="fieldset-item">
          <picture aria-hidden="true">
            <svg viewBox="0 0 24 24">
              <title>An alarm clock icon</title>
              <path d="M22 5.72l-4.6-3.86-1.29 1.53 4.6 3.86L22 5.72zM7.88 3.39L6.6 1.86 2 5.71l1.29 1.53 4.59-3.85zM12.5 8H11v6l4.75 2.85.75-1.23-4-2.37V8zM12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/>
            </svg>
          </picture>
          <div class="input-stack">
            <label for="alarm-volume" id="alarm-volume" aria-hidden="true">Alarm volume</label>
            <input 
              name="alarm-volume" 
              aria-labelledby="alarm-volume" 
              type="range" 
              value="8" 
              max="10" 
              style="--track-fill: 80%"
            >
          </div>
        </div>
        
      </fieldset>
    </section>

    <section>
      <header>
        <h2>Notifications</h2>
        <small>Turn specific channels on/off</small>
      </header>
      
      <fieldset>

        <div class="fieldset-item">
          <input 
            type="checkbox"
            checked
            id="text-notifications"
            name="text-notifications"
          >
          <div class="input-stack">
            <label for="text-notifications">
              <h3>Text Messages</h3>
              <small>Get notified about all text messages sent to your device</small>
            </label>
          </div>
        </div>

        <div class="fieldset-item">
          <input 
            type="checkbox"
            id="voice-notifications"
            name="voice-notifications"
          >
          <div class="input-stack">
            <label for="voice-notifications">
              <h3>Voice Mail</h3>
              <small>Get notified about all voice messages sent to your device</small>
            </label>
          </div>
        </div>

        <div class="fieldset-item">
          <input 
            type="checkbox"
            id="email-notifications"
            name="email-notifications"
          >
          <div class="input-stack">
            <label for="email-notifications">
              <h3>Emails</h3>
              <small>Get notified about all text messages to your device</small>
            </label>
          </div>
        </div>

      </fieldset>
    </section>

  </form>
</main>

        @custom-media --motionOK (prefers-reduced-motion: no-preference);

:root {
  --surface1: lch(10 0 0);
  --surface2: lch(15 0 0);
  --surface3: lch(20 0 0);
  --surface4: lch(25 0 0);

  --text1: lch(95 0 0);
  --text2: lch(75 0 0);

  --brand: lch(64 20 237);
  --brand-bg1: lch(70 64 349);
  --brand-bg2: lch(60 84 300);

  --brand-bg-gradient: linear-gradient(
    to bottom, 
    var(--brand-bg1), 
    var(--brand-bg2)
  );

  --thumb-highlight-color: lch(100 0 0 / 20%);

  --space-xxs: .25rem;
  --space-xs:  .5rem;
  --space-sm:  1rem;
  --space-md:  1.5rem;
  --space-lg:  2rem;
  --space-xl:  3rem;
  --space-xxl: 6rem;
  
  --isLTR: 1;
  --isRTL: -1;
  
  &:dir(rtl) {
    --isLTR: -1;
    --isRTL: 1;
  }

  @media (prefers-color-scheme: light) {
    & {
      --surface1: lch(90 0 0);
      --surface2: lch(100 0 0);
      --surface3: lch(98 0 0);
      --surface4: lch(85 0 0);

      --text1: lch(20 0 0);
      --text2: lch(40 0 0);

      --brand: lch(64 40 237);
      --brand-bg1: lch(50 64 349);
      --brand-bg2: lch(40 84 300);

      --thumb-highlight-color: lch(0 0 0 / 20%);
    }
  }
}

html {
  block-size: 100%;
  inline-size: 100%;
}

body {
  min-block-size: 100%;
  min-inline-size: 100%;

  box-sizing: border-box;
  margin: 0;
  padding-block: var(--space-xs);

  background: var(--surface1);
  color: var(--text1);
  font-family: system-ui, sans-serif;
}

h1,h2,h3 { 
  margin: 0; 
  font-weight: 500;
}

main {
  display: grid;
  gap: var(--space-xl);
  place-content: center;
  padding: var(--space-sm);

  @media (width >= 540px) {
    padding: var(--space-lg);
  }

  @media (width >= 800px) {
    padding: var(--space-xl);
  }
}

form {
  max-width: 89vw;
  display: grid;
  gap: var(--space-xl) var(--space-xxl);
  --repeat: auto-fit;
  grid-template-columns: 
    repeat(var(--repeat), minmax(min(10ch, 100%), 35ch));
  align-items: flex-start;

  @media (orientation: landscape) and (width >= 640px) {
    --repeat: 2;
  }
}

section {
  display: grid;
  gap: var(--space-md);
}

header {
  display: grid;
  gap: var(--space-xxs);
}

fieldset {
  border: 1px solid var(--surface4);
  background: var(--surface4);
  padding: 0;
  margin: 0;
  display: grid;
  gap: 1px;
  border-radius: var(--space-sm);
  overflow: hidden;
  transition: box-shadow .3s ease;

  &:focus-within {
    box-shadow: 0 5px 20px -10px hsl(0 0% 0% / 50%);
  }
}

input[type="range"] {
  --track-height: .5ex;
  --track-fill: 0%;
  --thumb-size: 3ex;
  --thumb-offset: -1.25ex;
  --thumb-highlight-size: 0px;

  display: block;
  inline-size: 100%;
  margin: 1ex 0;
  appearance: none;
  background: transparent;
  outline-offset: 5px;

  @media (hover: none) {
    --thumb-size: 30px;
    --thumb-offset: -14px;
  }

  &::-webkit-slider-runnable-track {
    appearance: none;
    block-size: var(--track-height);
    border-radius: 5ex;
    background: 
      linear-gradient(
        to right, 
        transparent var(--track-fill), 
        var(--surface1) 0%
      ),
      var(--brand-bg-gradient) fixed;
  }

  &::-moz-range-track {
    appearance: none;
    block-size: var(--track-height);
    border-radius: 5ex;
    background: 
      linear-gradient(
        to right, 
        transparent var(--track-fill), 
        var(--surface1) 0%
      ),
      var(--brand-bg-gradient) fixed;
  }

  &::-webkit-slider-thumb {
    appearance: none;
    cursor: ew-resize;
    border: 3px solid var(--surface3);
    block-size: var(--thumb-size);
    inline-size: var(--thumb-size);
    margin-block-start: var(--thumb-offset);
    border-radius: 50%;
    background: var(--brand-bg-gradient) fixed;
    box-shadow: 0 0 0 var(--thumb-highlight-size) var(--thumb-highlight-color);
    
    @media (--motionOK) {
      transition: box-shadow .1s ease;
    }

    @nest .fieldset-item:focus-within & {
      border-color: var(--surface2);
    }
  }

  &::-moz-range-thumb {
    appearance: none;
    cursor: ew-resize;
    border: 3px solid var(--surface3);
    block-size: var(--thumb-size);
    inline-size: var(--thumb-size);
    margin-block-start: var(--thumb-offset);
    border-radius: 50%;
    background: var(--brand-bg-gradient) fixed;
    box-shadow: 0 0 0 var(--thumb-highlight-size) var(--thumb-highlight-color);

    @media (--motionOK) {
      transition: box-shadow .1s ease;
    }

    @nest .fieldset-item:focus-within & {
      border-color: var(--surface2);
    }
  }

  &:is(:hover,:active) {
    --thumb-highlight-size: 10px;
  }
}

input[type="checkbox"] {
  inline-size: var(--space-sm);
  block-size: var(--space-sm);
  margin: 0;
  outline-offset: 5px;
  accent-color: var(--brand);
  position: relative;
  transform-style: preserve-3d;
  cursor: pointer;

  &:hover::before {
    --thumb-scale: 1;
  }

  @media (hover: none) {
    inline-size: var(--space-md);
    block-size: var(--space-md);
  }

  &::before {
    --thumb-scale: .01;
    --thumb-highlight-size: var(--space-xl);

    content: "";
    inline-size: var(--thumb-highlight-size);
    block-size: var(--thumb-highlight-size);
    clip-path: circle(50%);
    position: absolute;
    inset-block-start: 50%;
    inset-inline-start: 50%;
    background: var(--thumb-highlight-color);
    transform-origin: center center;
    transform: 
      translateX(calc(var(--isRTL) * 50%)) 
      translateY(-50%) 
      translateZ(-1px) 
      scale(var(--thumb-scale))
    ;
    will-change: transform;

    @media (--motionOK) {
      transition: transform .2s ease;
    }
  }
}

.fieldset-item {
  background: var(--surface3);
  transition: background .2s ease;

  display: grid;
  grid-template-columns: var(--space-lg) 1fr;
  gap: var(--space-md);

  padding-block: var(--space-sm);
  padding-inline: var(--space-md);

  @media (width >= 540px) {
    grid-template-columns: var(--space-xxl) 1fr;
    gap: var(--space-xs);
    padding-block: var(--space-md);
    padding-inline: 0 var(--space-xl);
  }

  &:focus-within {
    background: var(--surface2);

    & svg {
      fill: white;
    }

    & picture {
      clip-path: circle(50%);
      background: var(--brand-bg-gradient) fixed;
    }
  }

  & > :is(.input-stack, label) {
    display: grid;
    gap: var(--space-xs);
  }

  & > .input-stack > label {
    display: contents;
  }

  & > picture {
    block-size: var(--space-xl);
    inline-size: var(--space-xl);
    clip-path: circle(40%);
    display: inline-grid;
    place-content: center;
    background: var(--surface3) fixed;

    @media (--motionOK) {
      transition: clip-path .3s ease;
    }
  }

  & svg {
    fill: var(--text2);
    block-size: var(--space-md);
  }

  & > :is(picture, input[type="checkbox"]) {
    place-self: center;
  }
}

small {
  color: var(--text2);
  line-height: 1.5;
}
        

        const form = document.querySelector('form')
const sliders = document.querySelectorAll('input[type="range"]')

const rangeToPercent = slider => {
  const max = slider.getAttribute('max') || 10
  const percent = slider.value / max * 100

  return `${parseInt(percent)}%`
}

sliders.forEach(slider => {
  slider.style.setProperty('--track-fill', rangeToPercent(slider))

  slider.addEventListener('input', e => {
    e.target.style.setProperty('--track-fill', rangeToPercent(e.target))
  })
})

form.addEventListener('input', e => {
  const formData = Object.fromEntries(new FormData(form))
  console.table(formData)
})