Halaman Web Pertama: HTML, CSS, dan JavaScript
Aplikasi to-do yang tadinya hanya jalan di terminal, sekarang dibuat ulang sebagai halaman web yang bisa dibuka di browser mana pun.
Oleh Afa Hernandes15 menit baca
Daftar isi
- Sebelum mulai
- Langkah 1: HTML, kerangka halamannya
- Langkah 2: CSS, membuatnya enak dilihat
- Langkah 3: JavaScript, data dan cara menampilkannya
- Langkah 4: Menambah, mencentang, dan menghapus
- Langkah 5: Simpan di localStorage
- Hasil
- Kode lengkap
- Error yang sering muncul
- Latihan
- Langkah berikutnya: rilis dan amankan
- Pertanyaan yang sering diajukan
- Apakah saya perlu belajar React atau framework lain dulu?
- Apa bedanya localStorage dengan database?
- Kenapa tidak dibuka lewat server saja, seperti di tutorial lain?
- Kesimpulan
Di proyek mini sebelumnya kamu membuat aplikasi to-do di terminal dengan Python. Programnya bekerja, tapi hanya bisa dipakai di komputer yang menjalankan Python, lewat layar hitam berisi teks. Kebanyakan orang tidak memakai aplikasi seperti itu. Mereka membuka browser.
Di tutorial ini kamu membuat aplikasi to-do yang sama sebagai halaman web. Browser hanya mengerti tiga bahasa, dan masing-masing punya tugas sendiri:
- HTML: isi dan struktur halaman. Ada judul, kotak isian, tombol, dan daftar.
- CSS: tampilan. Warna, jarak, ukuran huruf, dan susunan elemen.
- JavaScript: perilaku. Apa yang terjadi saat tombol diklik, dan bagaimana data disimpan.
Hasil akhirnya satu folder berisi tiga file yang bisa dibuka dengan dobel-klik, lalu di artikel berikutnya bisa langsung kamu online-kan. Bentuk datanya sama persis dengan versi Python, jadi kamu akan melihat bahwa konsep yang kamu pelajari tidak terikat pada satu bahasa.
Sebelum mulai
- VS Code dan sebuah browser modern (Chrome, Edge, atau Firefox). Kalau belum punya VS Code, ikuti menyiapkan VS Code dan terminal.
- Sebaiknya kamu sudah membuat versi terminalnya, karena tutorial ini sering membandingkan keduanya. Tapi tanpa itu pun kamu tetap bisa mengikuti.
- Tidak perlu memasang apa pun lagi: tanpa Node, tanpa framework, tanpa server.
- Diuji di Chrome 157 di Windows 11, baik dibuka dengan dobel-klik maupun lewat server lokal.
Buat folder proyek dengan susunan ini:
todo-web/└── public/ ├── index.html ├── style.css └── app.jsKenapa ada folder public? Karena nanti seluruh isi folder itu yang di-deploy ke internet, dan semua yang di-deploy bisa diunduh siapa saja. Memisahkan file publik sejak awal membuat file lain (catatan pribadi, file rahasia) tidak ikut terunggah tanpa sengaja. Alasannya dibahas di deploy pertama.
Langkah 1: HTML, kerangka halamannya
Buat public/index.html:
<!doctype html><html lang="id"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>To-Do Ku</title> <link rel="stylesheet" href="style.css"> <script src="app.js" defer></script></head><body> <main class="kartu"> <h1>To-Do Ku</h1>
<form id="form-tugas"> <input id="judul" aria-label="Judul tugas" placeholder="Mau mengerjakan apa?" maxlength="100" autocomplete="off" required> <button type="submit">Tambah</button> </form>
<ul id="daftar"></ul> <p id="ringkasan"></p> </main></body></html>HTML terdiri dari tag seperti <h1>…</h1>: tag pembuka, isi, lalu tag penutup. Beberapa tag, seperti <input> dan <meta>, tidak punya penutup. Yang perlu kamu pahami dari file ini:
<head>berisi keterangan tentang halaman, tidak tampil di layar.<body>berisi yang tampil.<meta charset="utf-8">supaya semua huruf tampil benar.<meta name="viewport" ...>supaya halaman pas di layar HP, tidak tampil mengecil seperti layar komputer.<link rel="stylesheet" href="style.css">menyambungkan file CSS, dan<script src="app.js" defer>menyambungkan file JavaScript.deferartinya “jalankan JavaScript setelah seluruh HTML selesai dibaca”. Tanpadefer, script dijalankan sebelum<ul id="daftar">ada, lalu gagal. Ini kesalahan paling umum pemula, dan pesan error-nya ada di bagian bawah.id="..."memberi nama unik pada elemen, supaya bisa dicari oleh CSS dan JavaScript.<form>dipakai untuk kotak isian dan tombolnya. Keuntungannya, menekan Enter di kotak isian otomatis sama dengan mengklik tombol.requireddanmaxlength="100"membuat browser menolak isian kosong dan membatasi panjangnya, tanpa satu baris JavaScript pun.aria-labelmemberi nama kotak isian untuk pembaca layar yang dipakai pengguna tunanetra, karena kotak ini tidak punya label yang tampil.
Dobel-klik index.html. Halamannya terbuka di browser: polos, tapi semua bagiannya sudah ada.
Langkah 2: CSS, membuatnya enak dilihat
Buat public/style.css. Bagian terpenting ada di awal:
:root { --latar: #f6f1eb; --kartu: #ffffff; --teks: #1f2328; --redup: #6e7781; --utama: #b45309; --garis: #e5e0d8;}
body { margin: 0; min-height: 100vh; padding: 48px 16px; background: var(--latar); color: var(--teks); font-family: system-ui, sans-serif; line-height: 1.5;}
.kartu { max-width: 480px; margin: 0 auto; padding: 24px; background: var(--kartu); border-radius: 16px; box-shadow: 0 8px 24px rgb(0 0 0 / 0.08);}
form { display: flex; gap: 8px;}
#judul { flex: 1; /* ...selengkapnya di bagian Kode lengkap */}CSS terdiri dari selector (elemen mana yang diatur) dan deklarasi (aturannya). body mengatur tag <body>, .kartu mengatur elemen dengan class="kartu", dan #judul mengatur elemen dengan id="judul".
--utama: #b45309adalah variabel CSS. Warna ditulis sekali di:root, lalu dipakai di mana-mana denganvar(--utama). Mau ganti warna utama? Cukup ubah satu baris.max-width: 480pxdanmargin: 0 automembuat kartu tidak melebar di layar besar dan selalu di tengah. Di HP, kartunya menyesuaikan lebar layar.display: flexmenyusun kotak isian dan tombol berdampingan, danflex: 1membuat kotak isian mengambil sisa ruang. Flexbox dibahas lengkap di Flexbox dan Grid CSS.
Salin isi lengkap style.css dari bagian Kode lengkap di bawah, lalu muat ulang halaman (F5). Tampilannya langsung berubah.
Langkah 3: JavaScript, data dan cara menampilkannya
Sekarang bagian yang membuat halaman ini hidup. Buat public/app.js dan mulai dari data dan tampilannya:
const form = document.querySelector('#form-tugas');const input = document.querySelector('#judul');const daftar = document.querySelector('#daftar');const ringkasan = document.querySelector('#ringkasan');
let tugas = [ { judul: 'Beli kopi', selesai: true }, { judul: 'Kerjakan tugas Python', selesai: false },];tampilkan();
function tampilkan() { daftar.replaceChildren(); // kosongkan daftar, lalu gambar ulang
tugas.forEach((t, i) => { const li = document.createElement('li'); li.classList.toggle('selesai', t.selesai);
const teks = document.createElement('span'); teks.textContent = t.judul; // textContent, bukan innerHTML
li.append(teks); daftar.append(li); });
const sisa = tugas.filter((t) => !t.selesai).length; ringkasan.textContent = tugas.length === 0 ? 'Belum ada tugas. Tambahkan di atas.' : `${sisa} dari ${tugas.length} tugas belum selesai.`;}Bandingkan dengan versi Python. Bentuk datanya sama: list (di JavaScript disebut array) berisi object (padanan dictionary). Bedanya hanya di penulisan:
| Python | JavaScript |
|---|---|
tugas = [{"judul": "Beli kopi", "selesai": True}] |
let tugas = [{ judul: 'Beli kopi', selesai: true }]; |
for nomor, t in enumerate(tugas): |
tugas.forEach((t, i) => { … }); |
len(tugas) |
tugas.length |
not t["selesai"] |
!t.selesai |
f"{sisa} dari {len(tugas)}" |
`${sisa} dari ${tugas.length}` |
"x" if selesai else " " |
selesai ? 'x' : ' ' |
Yang baru adalah DOM, yaitu cara JavaScript melihat dan mengubah isi halaman:
document.querySelector('#daftar')mencari elemen dengan selector yang sama seperti di CSS.document.createElement('li')membuat elemen baru, dandaftar.append(li)memasukkannya ke halaman.li.classList.toggle('selesai', t.selesai)memasang classselesaikalau tugasnya selesai. CSS lalu mencoretnya.- Fungsi
tampilkan()selalu menggambar ulang seluruh daftar dari data. Pola ini sederhana dan susah salah: setiap kali data berubah, ubah datanya, lalu panggiltampilkan().
Kenapa textContent, bukan innerHTML? textContent selalu menampilkan isinya sebagai teks biasa. Kalau seseorang mengetik <img src=x onerror="..."> sebagai judul tugas, innerHTML akan menjalankannya sebagai kode, sedangkan textContent hanya menampilkannya apa adanya. Ini bukan soal gaya. Ini pencegahan celah XSS, salah satu celah keamanan web yang paling sering ditemukan.
Muat ulang halaman. Dua tugas contoh tampil, dan yang pertama tercoret.
Langkah 4: Menambah, mencentang, dan menghapus
Ganti dua tugas contoh dengan list kosong (let tugas = [];), lalu tambahkan kode yang menanggapi event, yaitu kejadian seperti klik atau kirim form. Tambahkan setelah tampilkan(); di bagian atas:
form.addEventListener('submit', (event) => { event.preventDefault(); // jangan muat ulang halaman const judul = input.value.trim(); if (!judul) return;
tugas.push({ judul, selesai: false }); tampilkan(); form.reset(); input.focus();});addEventListener('submit', ...)artinya “saat form dikirim, jalankan fungsi ini”. Form dikirim saat tombol diklik atau Enter ditekan.- Secara bawaan, mengirim form membuat browser memuat ulang halaman.
event.preventDefault()mencegahnya, karena semuanya kita tangani sendiri di JavaScript. .trim()sama dengan.strip()di Python. Isian berisi spasi saja tetap ditolak.{ judul, selesai: false }adalah singkatan dari{ judul: judul, selesai: false }.form.reset()mengosongkan kotak isian, daninput.focus()mengembalikan kursor ke sana supaya kamu bisa langsung mengetik tugas berikutnya.
Untuk centang dan tombol hapus, lengkapi isi forEach di tampilkan(). Setiap baris tugas mendapat kotak centang dan tombol Hapus sendiri:
tugas.forEach((t, i) => { const li = document.createElement('li'); li.classList.toggle('selesai', t.selesai);
const centang = document.createElement('input'); centang.type = 'checkbox'; centang.checked = t.selesai; centang.addEventListener('change', () => { t.selesai = centang.checked; tampilkan(); });
const teks = document.createElement('span'); teks.textContent = t.judul; // textContent, bukan innerHTML
const label = document.createElement('label'); label.append(centang, teks);
const tombolHapus = document.createElement('button'); tombolHapus.type = 'button'; tombolHapus.textContent = 'Hapus'; tombolHapus.setAttribute('aria-label', `Hapus ${t.judul}`); tombolHapus.addEventListener('click', () => { tugas.splice(i, 1); tampilkan(); });
li.append(label, tombolHapus); daftar.append(li); });- Kotak centang dan teksnya dibungkus
<label>, jadi mengklik teks tugasnya juga ikut mencentang. Area kliknya lebih luas, dan itu terasa sekali di HP. tugas.splice(i, 1)menghapus 1 item di posisii, sama dengantugas.pop(i)di Python.aria-labelmembuat pembaca layar menyebut “Hapus Beli kopi”, bukan sekadar “Hapus” berulang-ulang.- Perhatikan bahwa tidak ada pemeriksaan nomor seperti
pilih_nomor()di versi Python. Pengguna tidak mengetik nomor, mereka mengklik tombol milik tugas itu sendiri, jadi salah nomor tidak mungkin terjadi.
Coba tambah, centang, dan hapus beberapa tugas. Semuanya bekerja, tapi tekan F5: daftarnya kosong lagi. Masalahnya sama persis dengan versi terminal sebelum ada file JSON.
Langkah 5: Simpan di localStorage
Di versi Python, data disimpan ke file todo.json. Halaman web tidak boleh menulis file sembarangan di komputer pengunjung, tapi browser menyediakan localStorage: tempat penyimpanan kecil milik setiap situs, yang tetap ada walaupun halaman ditutup. localStorage hanya menyimpan teks, jadi datanya diubah ke JSON dulu, persis seperti di Python.
Tambahkan di paling atas app.js:
const KUNCI = 'todo-ku';Ganti let tugas = []; menjadi:
let tugas = muat();Tambahkan dua fungsi ini:
function muat() { try { return JSON.parse(localStorage.getItem(KUNCI)) ?? []; } catch { return []; // data rusak atau localStorage diblokir: mulai dari kosong }}
function simpan() { localStorage.setItem(KUNCI, JSON.stringify(tugas));}Lalu panggil simpan(); tepat sebelum setiap tampilkan(); yang mengikuti perubahan data: di event submit, di event change kotak centang, dan di event click tombol hapus.
JSON.stringifymengubah array menjadi teks JSON (sepertijson.dump), danJSON.parsesebaliknya (sepertijson.load).- Saat pertama kali dibuka,
localStorage.getItemmenghasilkannull, danJSON.parse(null)juganull. Operator??menggantinullitu dengan array kosong. try/catchsama dengantry/exceptdi Python. Kalau datanya rusak, halaman tetap terbuka dengan daftar kosong, tidak macet.
Tambah beberapa tugas, tekan F5, bahkan tutup browsernya. Tugasmu tetap ada.
Hasil

Kamu bisa memeriksa isi localStorage-nya sendiri. Tekan F12 untuk membuka DevTools, pilih tab Application, lalu buka Local storage di panel kiri. Di sana ada kunci todo-ku berisi teks JSON yang bentuknya sama dengan todo.json di versi Python:
[{"judul":"Beli kopi","selesai":true},{"judul":"Kerjakan tugas Python","selesai":false}]Biasakan juga membuka tab Console di DevTools. Kalau JavaScript-mu error, pesannya muncul di situ dengan warna merah, lengkap dengan nama file dan nomor barisnya.
Kode lengkap
<!doctype html><html lang="id"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>To-Do Ku</title> <link rel="stylesheet" href="style.css"> <script src="app.js" defer></script></head><body> <main class="kartu"> <h1>To-Do Ku</h1>
<form id="form-tugas"> <input id="judul" aria-label="Judul tugas" placeholder="Mau mengerjakan apa?" maxlength="100" autocomplete="off" required> <button type="submit">Tambah</button> </form>
<ul id="daftar"></ul> <p id="ringkasan"></p> </main></body></html>:root { --latar: #f6f1eb; --kartu: #ffffff; --teks: #1f2328; --redup: #6e7781; --utama: #b45309; --garis: #e5e0d8;}
* { box-sizing: border-box;}
body { margin: 0; min-height: 100vh; padding: 48px 16px; background: var(--latar); color: var(--teks); font-family: system-ui, sans-serif; line-height: 1.5;}
.kartu { max-width: 480px; margin: 0 auto; padding: 24px; background: var(--kartu); border-radius: 16px; box-shadow: 0 8px 24px rgb(0 0 0 / 0.08);}
h1 { margin: 0 0 16px; font-size: 1.75rem;}
form { display: flex; gap: 8px;}
#judul { flex: 1; padding: 10px 12px; border: 1px solid var(--garis); border-radius: 8px; font: inherit;}
button { padding: 10px 16px; border: 0; border-radius: 8px; background: var(--utama); color: #fff; font: inherit; font-weight: 600; cursor: pointer;}
ul { list-style: none; margin: 16px 0 0; padding: 0;}
li { display: flex; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--garis);}
li label { flex: 1; display: flex; align-items: center; gap: 10px; cursor: pointer;}
li input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--utama);}
li.selesai span { color: var(--redup); text-decoration: line-through;}
li button { padding: 4px 10px; background: transparent; color: var(--redup); font-weight: 400;}
li button:hover { color: #b42318;}
#ringkasan { margin: 16px 0 0; color: var(--redup); font-size: 0.9rem;}const KUNCI = 'todo-ku';
const form = document.querySelector('#form-tugas');const input = document.querySelector('#judul');const daftar = document.querySelector('#daftar');const ringkasan = document.querySelector('#ringkasan');
let tugas = muat();tampilkan();
form.addEventListener('submit', (event) => { event.preventDefault(); // jangan muat ulang halaman const judul = input.value.trim(); if (!judul) return;
tugas.push({ judul, selesai: false }); simpan(); tampilkan(); form.reset(); input.focus();});
function muat() { try { return JSON.parse(localStorage.getItem(KUNCI)) ?? []; } catch { return []; // data rusak atau localStorage diblokir: mulai dari kosong }}
function simpan() { localStorage.setItem(KUNCI, JSON.stringify(tugas));}
function tampilkan() { daftar.replaceChildren(); // kosongkan daftar, lalu gambar ulang
tugas.forEach((t, i) => { const li = document.createElement('li'); li.classList.toggle('selesai', t.selesai);
const centang = document.createElement('input'); centang.type = 'checkbox'; centang.checked = t.selesai; centang.addEventListener('change', () => { t.selesai = centang.checked; simpan(); tampilkan(); });
const teks = document.createElement('span'); teks.textContent = t.judul; // textContent, bukan innerHTML
const label = document.createElement('label'); label.append(centang, teks);
const tombolHapus = document.createElement('button'); tombolHapus.type = 'button'; tombolHapus.textContent = 'Hapus'; tombolHapus.setAttribute('aria-label', `Hapus ${t.judul}`); tombolHapus.addEventListener('click', () => { tugas.splice(i, 1); simpan(); tampilkan(); });
li.append(label, tombolHapus); daftar.append(li); });
const sisa = tugas.filter((t) => !t.selesai).length; ringkasan.textContent = tugas.length === 0 ? 'Belum ada tugas. Tambahkan di atas.' : `${sisa} dari ${tugas.length} tugas belum selesai.`;}Error yang sering muncul
Uncaught TypeError: Cannot read properties of null (reading 'replaceChildren')
Muncul di Console kalau defer lupa ditulis di tag <script>. Script dijalankan sebelum elemen #daftar dibuat, jadi document.querySelector('#daftar') menghasilkan null. Tambahkan defer. Pesan yang sama dengan nama lain di dalam kurung, misalnya (reading 'addEventListener'), biasanya berarti selector-mu salah ketik atau id-nya tidak ada di HTML.
Tampilan tidak berubah setelah CSS diedit
Periksa href="style.css" di HTML: nama dan lokasi filenya harus sama persis, termasuk huruf besar dan kecil. Di DevTools, tab Network menampilkan style.css dengan status 404 kalau filenya tidak ketemu. Kalau path-nya sudah benar, tekan Ctrl+F5 untuk memuat ulang tanpa cache.
Tugas hilang saat dibuka di browser lain atau mode Incognito Itu memang sifat localStorage: datanya tersimpan per browser dan per situs. Chrome dan Edge punya penyimpanan masing-masing, dan mode Incognito menghapusnya begitu semua jendela Incognito ditutup. Data yang tersinkron antarperangkat butuh server dan database, dibahas di menambahkan database dan login.
Halaman termuat ulang dan tugas tidak bertambah saat Enter ditekan
event.preventDefault() terlewat, atau ada error lain sebelum baris itu. Buka tab Console dan cari baris merah pertama.
Panduan umum membaca pesan error (contohnya Python, tapi cara berpikirnya sama) ada di membaca pesan error.
Latihan
- Tambahkan tombol “Hapus yang selesai” di bawah daftar (petunjuk:
tugas.filter(...), lalusimpan()dantampilkan()). - Buat dark mode otomatis: di CSS, tambahkan
@media (prefers-color-scheme: dark)yang mengganti nilai variabel di:root. Karena semua warna memakai variabel, kamu tidak perlu mengubah aturan lain. - Tampilkan tanggal dibuat di setiap tugas (petunjuk: simpan
new Date().toISOString()di object tugas).
Langkah berikutnya: rilis dan amankan
Folder public ini sudah siap online. Ikuti deploy pertama ke Cloudflare Pages: seret folder public ke dashboard, dan dalam beberapa menit aplikasimu punya alamat yang bisa dibuka di HP mana pun. Setiap pengunjung punya daftar tugasnya sendiri, karena localStorage tersimpan di browser masing-masing.
Dua hal keamanan yang perlu kamu ingat:
- Tampilkan input pengguna dengan
textContent, bukaninnerHTML. Sudah kamu terapkan di sini, dan alasannya dibahas di XSS dan CSRF. - localStorage bukan tempat menyimpan rahasia. Semua JavaScript yang berjalan di halamanmu bisa membacanya, dan pemilik komputer bisa melihatnya di DevTools. Daftar tugas boleh, kata sandi atau kunci API tidak.
Pertanyaan yang sering diajukan
Apakah saya perlu belajar React atau framework lain dulu?
Tidak. Framework dibangun di atas HTML, CSS, dan JavaScript yang baru kamu pakai. Memahami dasarnya lebih dulu membuat framework jauh lebih mudah dipahami nanti. Lanjutan dari sini ada di jalur Web Developer Modern, mulai dari JavaScript modern.
Apa bedanya localStorage dengan database?
localStorage hanya ada di satu browser, kapasitasnya kecil, dan hanya bisa dibaca dari browser itu. Database ada di server, bisa diakses dari banyak perangkat, dan bisa dipakai bersama banyak pengguna. Untuk aplikasi pribadi sederhana, localStorage sudah cukup.
Kenapa tidak dibuka lewat server saja, seperti di tutorial lain?
Untuk halaman sederhana tanpa fetch dan tanpa module seperti ini, dobel-klik index.html sudah cukup. Begitu kamu mulai mengambil data dari file atau API dengan fetch, kamu butuh server lokal, karena browser membatasi apa yang boleh dilakukan halaman yang dibuka langsung dari file.
Kesimpulan
HTML menyusun isi, CSS mengatur tampilan, dan JavaScript menghidupkan halaman. Polanya sama dengan versi terminal: data berupa array berisi object, setiap perubahan disimpan sebagai JSON, lalu tampilan digambar ulang dari data. Bedanya, sekarang aplikasimu bisa dibuka siapa pun yang punya browser.
Langkah berikutnya di jalur Mulai Ngoding dari Nol adalah yang paling memuaskan: online-kan aplikasimu supaya bisa dibuka dari HP dan dibagikan ke teman.



