Cài đặt

Mẫu này cho biết cách tạo một thành phần cài đặt có khả năng thích ứng, hỗ trợ nhiều đầu vào thiết bị và hoạt động trên nhiều trình duyệt.

Bài viết đầy đủ · Video trên YouTube · Nguồn trên GitHub

HTML

<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>

CSS


        @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;
}
        

JS


        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)
})