ngodingdulu
Web & DatabaseTutorialPemula

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

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:

Struktur folder
todo-web/
└── public/
├── index.html
├── style.css
└── app.js

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

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.
  • defer artinya “jalankan JavaScript setelah seluruh HTML selesai dibaca”. Tanpa defer, 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.
  • required dan maxlength="100" membuat browser menolak isian kosong dan membatasi panjangnya, tanpa satu baris JavaScript pun.
  • aria-label memberi 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:

public/style.css
: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: #b45309 adalah variabel CSS. Warna ditulis sekali di :root, lalu dipakai di mana-mana dengan var(--utama). Mau ganti warna utama? Cukup ubah satu baris.
  • max-width: 480px dan margin: 0 auto membuat kartu tidak melebar di layar besar dan selalu di tengah. Di HP, kartunya menyesuaikan lebar layar.
  • display: flex menyusun kotak isian dan tombol berdampingan, dan flex: 1 membuat 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:

public/app.js
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, dan daftar.append(li) memasukkannya ke halaman.
  • li.classList.toggle('selesai', t.selesai) memasang class selesai kalau 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 panggil tampilkan().

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:

public/app.js
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, dan input.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:

public/app.js
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 posisi i, sama dengan tugas.pop(i) di Python.
  • aria-label membuat 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:

public/app.js
const KUNCI = 'todo-ku';

Ganti let tugas = []; menjadi:

public/app.js
let tugas = muat();

Tambahkan dua fungsi ini:

public/app.js
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.stringify mengubah array menjadi teks JSON (seperti json.dump), dan JSON.parse sebaliknya (seperti json.load).
  • Saat pertama kali dibuka, localStorage.getItem menghasilkan null, dan JSON.parse(null) juga null. Operator ?? mengganti null itu dengan array kosong.
  • try/catch sama dengan try/except di Python. Kalau datanya rusak, halaman tetap terbuka dengan daftar kosong, tidak macet.

Tambah beberapa tugas, tekan F5, bahkan tutup browsernya. Tugasmu tetap ada.

Hasil

Halaman To-Do Ku di Chrome: kotak isian dan tombol Tambah, tiga tugas dengan Beli kopi tercentang dan tercoret, lalu ringkasan 2 dari 3 tugas belum selesai

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:

Isi localStorage todo-ku
[{"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

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>
public/style.css
: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;
}
public/app.js
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

  1. Tambahkan tombol “Hapus yang selesai” di bawah daftar (petunjuk: tugas.filter(...), lalu simpan() dan tampilkan()).
  2. 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.
  3. 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, bukan innerHTML. 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.

Bagikan:WhatsAppTelegramXFacebook
Ilustrasi jendela browser berisi tiga tahap belajar web: fondasi, komponen dan framework, lalu full-stack, diakhiri bendera online
WebRoadmap

Roadmap Web Developer 2026 untuk Pemula

Tiga level berurutan, dari halaman HTML pertama sampai aplikasi full-stack yang online. Lengkap dengan urutan belajar dan tanda kapan kamu siap naik level.

23 Oktober 20266 mnt

Ilustrasi dua kartu bersebelahan: kartu kiri berisi daftar alat lama seperti XAMPP, mysql_query, jQuery, dan FTP, kartu kanan berisi padanan modernnya seperti PHP 8, prepared statement, fetch, dan git push
WebKonsep

Apa yang Berubah di Dunia Web Sejak Era XAMPP

Kalau terakhir ngoding web dengan XAMPP, PHP native, jQuery, dan FTP, ini peta yang berubah, yang tetap sama, dan urutan mengejarnya tanpa membuang pengalamanmu.

6 November 202610 mnt

Enter bukaEsc tutupHalaman pencarian