Ringkasan dasar tentang cara membuat komponen tombol yang responsif dan aksesibel.
Dalam postingan ini, saya ingin membagikan pemikiran tentang cara membuat komponen tombol. Coba demo.
Jika Anda lebih suka menonton video, berikut versi YouTube dari postingan ini:
Ringkasan
Tombol geser berfungsi mirip dengan kotak centang tetapi secara eksplisit merepresentasikan status boolean aktif dan nonaktif.
Demo ini menggunakan <input type="checkbox" role="switch">
untuk sebagian besar fungsinya, yang memiliki keunggulan tidak memerlukan CSS atau JavaScript agar berfungsi penuh dan dapat diakses. Memuat CSS menghadirkan dukungan untuk bahasa kanan ke kiri, vertikalitas, animasi, dan lainnya. Memuat JavaScript membuat tombol
dapat ditarik dan terlihat nyata.
Properti kustom
Variabel berikut mewakili berbagai bagian pengalihan dan opsinya. Sebagai class tingkat teratas, .gui-switch
berisi properti kustom yang digunakan di seluruh turunan komponen, dan titik entri untuk penyesuaian terpusat.
Lacak
Panjang (--track-size
), padding, dan dua warna:
.gui-switch {
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-inactive: hsl(80 0% 80%);
--track-active: hsl(80 60% 45%);
--track-color-inactive: var(--track-inactive);
--track-color-active: var(--track-active);
@media (prefers-color-scheme: dark) {
--track-inactive: hsl(80 0% 35%);
--track-active: hsl(80 60% 60%);
}
}
Kalimba
Ukuran, warna latar belakang, dan warna sorotan interaksi:
.gui-switch {
--thumb-size: 2rem;
--thumb: hsl(0 0% 100%);
--thumb-highlight: hsl(0 0% 0% / 25%);
--thumb-color: var(--thumb);
--thumb-color-highlight: var(--thumb-highlight);
@media (prefers-color-scheme: dark) {
--thumb: hsl(0 0% 5%);
--thumb-highlight: hsl(0 0% 100% / 25%);
}
}
Pengurangan gerakan
Untuk menambahkan alias yang jelas dan mengurangi pengulangan, kueri media preferensi gerakan yang dikurangi pengguna dapat dimasukkan ke dalam properti kustom dengan plugin PostCSS berdasarkan draf spesifikasi ini di Media Queries 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Markup
Saya memilih untuk membungkus elemen <input type="checkbox" role="switch">
dengan
<label>
, menggabungkan hubungan keduanya untuk menghindari ambiguitas
asosiasi kotak centang dan label, sekaligus memberi pengguna kemampuan untuk
berinteraksi dengan label untuk mengalihkan input.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
sudah dibuat sebelumnya dengan
API
dan status. Browser mengelola properti checked
dan peristiwa input seperti oninput
dan onchanged
.
Tata letak
Flexbox, petak, dan properti kustom sangat penting dalam mempertahankan gaya komponen ini. Mereka memusatkan nilai, memberi nama pada perhitungan atau area yang ambigu, dan mengaktifkan API properti kustom kecil untuk memudahkan penyesuaian komponen.
.gui-switch
Tata letak tingkat teratas untuk tombol adalah flexbox. Class .gui-switch
berisi
properti kustom pribadi dan publik yang digunakan turunan untuk menghitung
tata letaknya.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Memperluas dan mengubah tata letak flexbox sama seperti mengubah tata letak flexbox lainnya.
Misalnya, untuk menempatkan label di atas atau di bawah tombol, atau untuk mengubah
flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Lacak
Input kotak centang diberi gaya sebagai jalur tombol geser dengan menghapus appearance: checkbox
normalnya
dan memberikan ukurannya sendiri:
.gui-switch > input {
appearance: none;
inline-size: var(--track-size);
block-size: var(--thumb-size);
padding: var(--track-padding);
flex-shrink: 0;
display: grid;
align-items: center;
grid: [track] 1fr / [track] 1fr;
}
Jalur ini juga membuat area jalur petak sel tunggal satu per satu untuk diklaim ibu jari.
Kalimba
Gaya appearance: none
juga menghapus tanda centang visual yang disediakan oleh
browser. Komponen ini menggunakan
pseudo-element dan :checked
pseudo-class pada input untuk
menggantikan indikator visual ini.
Thumb adalah turunan pseudo-elemen yang dilampirkan ke input[type="checkbox"]
dan ditumpuk di atas jalur, bukan di bawahnya, dengan mengklaim area petak track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Gaya
Properti kustom memungkinkan komponen tombol yang serbaguna dan beradaptasi dengan skema warna, bahasa kanan-ke-kiri, dan preferensi gerakan.
Gaya interaksi sentuh
Di perangkat seluler, browser menambahkan fitur pemilihan teks dan sorotan ketuk ke label dan
input. Hal ini berdampak negatif pada gaya dan masukan interaksi visual yang diperlukan oleh peralihan ini. Dengan beberapa baris CSS, saya dapat menghapus efek tersebut dan menambahkan gaya cursor: pointer
saya sendiri:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Tidak selalu disarankan untuk menghapus gaya tersebut, karena dapat memberikan masukan interaksi visual yang berharga. Pastikan untuk memberikan alternatif kustom jika Anda menghapusnya.
Lacak
Sebagian besar gaya elemen ini berkaitan dengan bentuk dan warnanya, yang diakses
dari .gui-switch
induk melalui
cascade.
.gui-switch > input {
appearance: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
}
Berbagai opsi penyesuaian untuk jalur tombol berasal dari empat properti kustom. border: none
ditambahkan karena appearance: none
tidak
menghapus batas dari kotak centang di semua browser.
Kalimba
Elemen thumb sudah berada di sebelah kanan track
, tetapi memerlukan gaya lingkaran:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interaksi
Gunakan properti kustom untuk mempersiapkan interaksi yang akan menampilkan sorotan saat mengarahkan kursor dan perubahan posisi ibu jari. Preferensi pengguna juga diperiksa sebelum melakukan transisi gaya sorotan gerakan atau melayang.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
Posisi ibu jari
Properti kustom menyediakan mekanisme sumber tunggal untuk memosisikan ibu jari di jalur. Kita memiliki ukuran jalur dan gambar mini yang akan kita gunakan dalam perhitungan untuk menjaga offset gambar mini dengan benar dan berada di dalam jalur:
0%
dan 100%
.
Elemen input
memiliki variabel posisi --thumb-position
, dan elemen
pseudo thumb menggunakannya sebagai posisi translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Kita sekarang dapat mengubah --thumb-position
dari CSS dan pseudo-class
yang disediakan pada elemen kotak centang. Karena kita menetapkan transition: transform
var(--thumb-transition-duration) ease
secara bersyarat sebelumnya pada elemen ini, perubahan ini
dapat dianimasikan saat diubah:
/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
Saya rasa orkestrasi yang tidak terikat ini berjalan dengan baik. Elemen thumb hanya berkaitan dengan satu gaya, yaitu posisi translateX
. Input dapat mengelola semua
kompleksitas dan perhitungan.
Vertical
Dukungan dilakukan dengan class pengubah -vertical
yang menambahkan rotasi dengan
transformasi CSS ke elemen input
.
Elemen yang diputar 3D tidak mengubah tinggi keseluruhan komponen,
yang dapat mengganggu tata letak blok. Perhitungkan hal ini menggunakan variabel --track-size
dan
--track-padding
. Hitung jumlah minimum ruang yang diperlukan agar tombol vertikal dapat mengalir dalam tata letak seperti yang diharapkan:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) kanan-ke-kiri
Seorang teman CSS, Elad Schecter, dan saya membuat prototipe bersama-sama menu samping geser keluar menggunakan transformasi CSS yang menangani bahasa kanan ke kiri dengan membalikkan satu variabel. Kami melakukan hal ini karena tidak ada transformasi properti logis di CSS, dan mungkin tidak akan pernah ada. Elad memiliki ide bagus untuk menggunakan nilai properti kustom untuk membalikkan persentase, sehingga memungkinkan pengelolaan satu lokasi untuk logika kustom kita sendiri untuk transformasi logis. Saya menggunakan teknik yang sama dalam peralihan ini dan menurut saya hasilnya sangat bagus:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Properti kustom yang disebut --isLTR
awalnya memiliki nilai 1
, yang berarti
true
karena tata letak kita adalah kiri-ke-kanan secara default. Kemudian, menggunakan
class semu CSS :dir()
,
nilai ditetapkan ke -1
saat komponen berada dalam tata letak kanan-ke-kiri.
Terapkan --isLTR
dengan menggunakannya dalam calc()
di dalam transformasi:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Sekarang rotasi tombol vertikal memperhitungkan posisi sisi berlawanan yang diperlukan oleh tata letak kanan-ke-kiri.
Transformasi translateX
pada elemen semu thumb juga perlu diperbarui untuk memperhitungkan persyaratan sisi berlawanan:
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
Meskipun pendekatan ini tidak akan berfungsi untuk memenuhi semua kebutuhan terkait konsep seperti transformasi CSS logis, pendekatan ini menawarkan beberapa prinsip DRY untuk banyak kasus penggunaan.
Negara bagian
Penggunaan input[type="checkbox"]
bawaan tidak akan lengkap tanpa menangani berbagai statusnya: :checked
, :disabled
, :indeterminate
, dan :hover
. :focus
sengaja dibiarkan, dengan
penyesuaian hanya dilakukan pada offset-nya; ring fokus terlihat bagus di Firefox dan
Safari:
Dicentang
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Status ini merepresentasikan status on
. Dalam status ini, latar belakang "jalur" input ditetapkan ke warna aktif dan posisi ibu jari ditetapkan ke "akhir".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Nonaktif
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Tombol :disabled
tidak hanya terlihat berbeda secara visual, tetapi juga harus membuat elemen tidak dapat diubah.Imutabilitas interaksi bebas dari browser, tetapi status visual memerlukan gaya karena penggunaan appearance: none
.
.gui-switch > input:disabled {
cursor: not-allowed;
--thumb-color: transparent;
&::before {
cursor: not-allowed;
box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);
@media (prefers-color-scheme: dark) { & {
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
}}
}
}
Status ini rumit karena memerlukan tema gelap dan terang dengan status dinonaktifkan dan dicentang. Saya memilih gaya minimalis untuk status ini secara stilistika untuk mengurangi beban pemeliharaan kombinasi gaya.
Tidak dapat ditentukan
Status yang sering dilupakan adalah :indeterminate
, saat kotak centang tidak dicentang atau tidak dicentang. Ini adalah status yang menyenangkan, mengundang, dan tidak mencolok. Pengingat yang baik bahwa status boolean dapat memiliki status di antaranya yang tidak terduga.
Menyetel kotak centang ke tidak ditentukan cukup rumit, hanya JavaScript yang dapat menyetelnya:
<label for="switch-indeterminate" class="gui-switch">
Indeterminate
<input type="checkbox" role="switch" id="switch-indeterminate">
<script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>
Karena statusnya bagi saya tidak mencolok dan menarik, saya merasa tepat untuk menempatkan posisi ibu jari tombol di tengah:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Arahkan kursor
Interaksi saat kursor diarahkan harus memberikan dukungan visual untuk UI yang terhubung dan juga memberikan petunjuk ke arah UI interaktif. Tombol ini menandai ibu jari dengan cincin semi-transparan saat label atau input diarahkan kursor. Animasi melayang ini kemudian memberikan arah menuju elemen thumbnail interaktif.
Efek "sorotan" dilakukan dengan box-shadow
. Saat kursor diarahkan ke input yang tidak dinonaktifkan, perbesar ukuran --highlight-size
. Jika pengguna menyetujui gerakan, kita akan melakukan transisi box-shadow
dan melihatnya bertambah, jika pengguna tidak menyetujui gerakan, sorotan akan muncul secara instan:
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
.gui-switch > input:not(:disabled):hover::before {
--highlight-size: .5rem;
}
JavaScript
Bagi saya, antarmuka tombol dapat terasa aneh dalam upaya meniru antarmuka fisik, terutama yang memiliki lingkaran di dalam jalur. iOS berhasil melakukannya dengan tombolnya, Anda dapat menariknya dari sisi ke sisi, dan opsi ini sangat memuaskan. Sebaliknya, elemen UI dapat terasa tidak aktif jika gestur tarik dicoba dan tidak ada yang terjadi.
Thumbnail yang dapat ditarik
Pseudo-elemen thumb menerima posisinya dari .gui-switch > input
yang tercakup var(--thumb-position)
, JavaScript dapat memberikan nilai gaya inline pada
input untuk memperbarui posisi thumb secara dinamis sehingga tampak mengikuti
gestur penunjuk. Saat pointer dilepaskan, hapus gaya inline dan tentukan apakah penarikan lebih dekat ke nonaktif atau aktif dengan menggunakan properti kustom --thumb-position
. Ini adalah tulang punggung solusi; peristiwa penunjuk
melacak posisi penunjuk secara kondisional untuk mengubah properti kustom CSS.
Karena komponen sudah 100% berfungsi sebelum skrip ini muncul, diperlukan banyak upaya untuk mempertahankan perilaku yang ada, seperti mengklik label untuk mengganti input. JavaScript kami tidak boleh menambahkan fitur dengan mengorbankan fitur yang sudah ada.
touch-action
Menyeret adalah gestur, gestur kustom, yang menjadikannya kandidat yang tepat untuk manfaat touch-action
. Dalam kasus tombol ini, gestur horizontal harus ditangani oleh skrip kita, atau gestur vertikal diambil untuk varian tombol vertikal. Dengan touch-action
, kita dapat memberi tahu browser gestur apa yang harus ditangani pada
elemen ini, sehingga skrip dapat menangani gestur tanpa persaingan.
CSS berikut menginstruksikan browser bahwa saat gestur penunjuk dimulai dari dalam jalur peralihan ini, tangani gestur vertikal, jangan lakukan apa pun dengan gestur horizontal:
.gui-switch > input {
touch-action: pan-y;
}
Hasil yang diinginkan adalah gestur horizontal yang tidak juga menggeser atau men-scroll halaman. Pointer dapat men-scroll secara vertikal mulai dari dalam input dan men-scroll halaman, tetapi pointer horizontal ditangani secara kustom.
Utilitas gaya nilai piksel
Saat penyiapan dan selama penarikan, berbagai nilai angka terkomputasi perlu diambil
dari elemen. Fungsi JavaScript berikut menampilkan nilai piksel yang dihitung
untuk properti CSS tertentu. Variabel ini digunakan dalam skrip penyiapan seperti ini
getStyle(checkbox, 'padding-left')
.
const getStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}
const getPseudoStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}
export {
getStyle,
getPseudoStyle,
}
Perhatikan bagaimana window.getComputedStyle()
menerima argumen kedua, yaitu elemen semu target. JavaScript dapat membaca begitu banyak nilai dari elemen, bahkan dari elemen semu.
dragging
Ini adalah momen inti untuk logika penarikan dan ada beberapa hal yang perlu diperhatikan dari pengendali peristiwa fungsi:
const dragging = event => {
if (!state.activethumb) return
let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
let directionality = getStyle(state.activethumb, '--isLTR')
let track = (directionality === -1)
? (state.activethumb.clientWidth * -1) + thumbsize + padding
: 0
let pos = Math.round(event.offsetX - thumbsize / 2)
if (pos < bounds.lower) pos = 0
if (pos > bounds.upper) pos = bounds.upper
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}
Hero skrip adalah state.activethumb
, lingkaran kecil yang diposisikan skrip ini bersama dengan penunjuk. Objek switches
adalah Map()
dengan
kuncinya adalah .gui-switch
dan nilainya adalah batas dan ukuran yang di-cache yang membuat
skrip tetap efisien. Kanan-ke-kiri ditangani menggunakan properti kustom yang sama
yang digunakan CSS untuk --isLTR
, dan dapat menggunakannya untuk membalikkan logika dan terus
mendukung RTL. event.offsetX
juga berharga, karena berisi nilai delta yang berguna untuk memosisikan ibu jari.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Baris CSS terakhir ini menetapkan properti kustom yang digunakan oleh elemen thumb. Penetapan
nilai ini akan bertransisi dari waktu ke waktu, tetapi peristiwa pointer sebelumnya
telah menetapkan --thumb-transition-duration
ke 0s
untuk sementara, sehingga menghilangkan
interaksi yang lambat.
dragEnd
Agar pengguna dapat menarik jauh di luar tombol dan melepaskannya, peristiwa jendela global perlu didaftarkan:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Menurut saya, sangat penting bagi pengguna untuk memiliki kebebasan menarik secara longgar dan antarmuka cukup pintar untuk memperhitungkannya. Tidak perlu banyak upaya untuk menanganinya dengan peralihan ini, tetapi perlu dipertimbangkan dengan cermat selama proses pengembangan.
const dragEnd = event => {
if (!state.activethumb) return
state.activethumb.checked = determineChecked()
if (state.activethumb.indeterminate)
state.activethumb.indeterminate = false
state.activethumb.style.removeProperty('--thumb-transition-duration')
state.activethumb.style.removeProperty('--thumb-position')
state.activethumb.removeEventListener('pointermove', dragging)
state.activethumb = null
padRelease()
}
Interaksi dengan elemen telah selesai, saatnya menetapkan properti input yang dicentang dan menghapus semua peristiwa gestur. Kotak centang diubah dengan
state.activethumb.checked = determineChecked()
.
determineChecked()
Fungsi ini, yang dipanggil oleh dragEnd
, menentukan posisi ibu jari saat ini
dalam batas jalurnya dan menampilkan nilai benar jika sama dengan atau lebih dari
setengah panjang jalur:
const determineChecked = () => {
let {bounds} = switches.get(state.activethumb.parentElement)
let curpos =
Math.abs(
parseInt(
state.activethumb.style.getPropertyValue('--thumb-position')))
if (!curpos) {
curpos = state.activethumb.checked
? bounds.lower
: bounds.upper
}
return curpos >= bounds.middle
}
Pikiran tambahan
Gestur tarik menimbulkan sedikit utang kode karena struktur HTML awal yang dipilih, terutama membungkus input dalam label. Label, yang merupakan elemen induk, akan menerima interaksi klik setelah input. Di akhir peristiwa
dragEnd
, Anda mungkin melihat padRelease()
sebagai fungsi
yang terdengar aneh.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Hal ini untuk memperhitungkan label yang mendapatkan klik berikutnya ini, karena akan membatalkan pilihan, atau memilih, interaksi yang dilakukan pengguna.
Jika saya melakukannya lagi, saya mungkin mempertimbangkan untuk menyesuaikan DOM dengan JavaScript selama upgrade UX, untuk membuat elemen yang menangani klik label itu sendiri dan tidak bertentangan dengan perilaku bawaan.
Jenis JavaScript ini adalah yang paling tidak saya sukai untuk ditulis, saya tidak ingin mengelola bubbling peristiwa kondisional:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Kesimpulan
Komponen tombol kecil ini ternyata menjadi yang paling sulit dari semua Tantangan GUI sejauh ini. Sekarang setelah Anda tahu cara saya melakukannya, bagaimana Anda‽ 🙂
Mari kita diversifikasi pendekatan kita dan pelajari semua cara untuk membangun di web. Buat demo, tweet linknya kepada saya, dan saya akan menambahkannya ke bagian remix komunitas di bawah.
Remix komunitas
- @KonstantinRouda dengan elemen kustom: demo dan kode.
- @jhvanderschee dengan tombol: Codepen.
Resource
Temukan .gui-switch
kode sumber di
GitHub.