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