Project Lanjutan
Lanjutan dari 4 project sebelumnya (Todo, Weather, Quiz, Expense Tracker). Di sini kita naik level, project-nya lebih kompleks, tapi tetap pakai vanilla JS. Setiap project dirancang untuk melatih skill spesifik yang bakal sering kamu pakai di dunia kerja.
Project 5: Stopwatch & Timer
Kenapa Project Ini?
Stopwatch kelihatan simpel, tapi di baliknya ada konsep yang fundamental banget: time management di JavaScript. Kamu bakal belajar bedanya setInterval vs setTimeout, kenapa kita nggak boleh percaya 100% sama interval timing, dan gimana cara bikin timer yang akurat.
Di dunia kerja, konsep ini muncul di mana-mana. Polling API setiap beberapa detik, auto-save draft setiap 30 detik, session timeout, animasi berbasis waktu. Semua pakai prinsip yang sama: simpan referensi waktu, hitung selisih, dan kelola state (jalan/berhenti/reset).
Skill yang dilatih: setInterval, clearInterval, time formatting (milidetik ke menit:detik:milidetik), state management sederhana, dan manipulasi DOM yang efisien.
Alur Logika
Sebelum nulis kode, kita pikirin dulu alurnya:
1. User klik "Start"
-> Simpan waktu mulai (Date.now())
-> Jalankan setInterval setiap 10ms
-> Setiap tick: hitung selisih (sekarang - waktuMulai + elapsedSebelumnya)
-> Format jadi MM:SS:ms -> Tampilkan di layar
2. User klik "Stop"
-> clearInterval (hentikan timer)
-> Simpan total elapsed time (biar bisa lanjut nanti)
3. User klik "Start" lagi (resume)
-> Waktu mulai = Date.now() (fresh)
-> Tapi elapsed sebelumnya tetap dihitung (ditambahkan)
-> Jadi timer LANJUT, bukan mulai dari 0
4. User klik "Reset"
-> clearInterval
-> Set elapsed = 0
-> Tampilan kembali ke 00:00:00
5. User klik "Lap"
-> Ambil waktu saat ini
-> Tambahkan ke array laps
-> Render list lap times
6. Mode Countdown:
-> User input durasi (menit)
-> Hitung endTime = Date.now() + durasi
-> Setiap tick: sisa = endTime - Date.now()
-> Kalau sisa <= 0, stop dan kasih alert/sound
Kenapa simpan startTime, bukan countdown elapsed? Karena setInterval(fn, 10) TIDAK dijamin tepat 10ms. Browser bisa delay kalau tab nggak aktif atau CPU sibuk. Kalau kita cuma tambah +10 setiap tick, timer bakal drift (makin lama makin nggak akurat). Dengan menyimpan startTime dan menghitung Date.now() - startTime, kita selalu dapat waktu yang benar, nggak peduli interval-nya telat atau nggak.
Fitur yang Akan Dibuat
- Start / Stop / Reset
- Lap times (catat waktu per putaran)
- Countdown timer mode
- Display format MM:SS:ms
- Visual feedback (warna berubah saat jalan)
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stopwatch & Timer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Stopwatch & Timer</h1>
<!-- Tab untuk switch mode -->
<div class="tabs">
<button class="tab active" data-mode="stopwatch">Stopwatch</button>
<button class="tab" data-mode="timer">Timer</button>
</div>
<!-- Display waktu -->
<div class="display" id="display">00:00:00</div>
<!-- Input untuk countdown (hidden by default) -->
<div class="timer-input" id="timerInput" style="display: none;">
<input type="number" id="minutes" placeholder="Menit" min="1" max="99">
<input type="number" id="seconds" placeholder="Detik" min="0" max="59">
</div>
<!-- Tombol kontrol -->
<div class="controls">
<button id="startBtn" class="btn btn-start">Start</button>
<button id="stopBtn" class="btn btn-stop" disabled>Stop</button>
<button id="resetBtn" class="btn btn-reset" disabled>Reset</button>
<button id="lapBtn" class="btn btn-lap" disabled>Lap</button>
</div>
<!-- List lap times -->
<ul id="laps" class="laps"></ul>
</div>
<script src="script.js"></script>
</body>
</html>Kenapa struktur begini? Tab di atas biar user bisa switch antara stopwatch dan countdown tanpa pindah halaman. Display terpisah dari kontrol supaya gampang di-style. Lap list pakai <ul> karena memang data berurutan.
Step 2: CSS Styling
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: #16213e;
padding: 2rem;
border-radius: 16px;
width: 100%;
max-width: 400px;
text-align: center;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
h1 { margin-bottom: 1.5rem; font-size: 1.4rem; }
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
justify-content: center;
}
.tab {
padding: 0.5rem 1.5rem;
border: none;
background: #0f3460;
color: #aaa;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.tab.active { background: #e94560; color: #fff; }
.display {
font-size: 3rem;
font-family: 'Courier New', monospace;
padding: 1.5rem;
background: #0f3460;
border-radius: 12px;
margin-bottom: 1.5rem;
letter-spacing: 2px;
transition: color 0.3s;
}
.display.running { color: #4ecca3; }
.display.countdown-warning { color: #e94560; }
.timer-input {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1rem;
}
.timer-input input {
width: 80px;
padding: 0.5rem;
border: none;
border-radius: 8px;
background: #0f3460;
color: #fff;
text-align: center;
font-size: 1.1rem;
}
.controls {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.btn {
padding: 0.7rem 1.2rem;
border: none;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
font-weight: 600;
}
.btn:active { transform: scale(0.95); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-start { background: #4ecca3; color: #1a1a2e; }
.btn-stop { background: #e94560; color: #fff; }
.btn-reset { background: #6c757d; color: #fff; }
.btn-lap { background: #533483; color: #fff; }
.laps {
list-style: none;
max-height: 200px;
overflow-y: auto;
text-align: left;
}
.laps li {
padding: 0.5rem 1rem;
background: #0f3460;
margin-bottom: 0.3rem;
border-radius: 6px;
display: flex;
justify-content: space-between;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}Step 3: JavaScript (Core Stopwatch)
// === STATE ===
// Kenapa pakai object untuk state? Biar semua data terkait timer
// terkumpul di satu tempat. Gampang di-debug, gampang di-reset.
const state = {
mode: 'stopwatch', // 'stopwatch' atau 'timer'
running: false,
startTime: 0, // kapan terakhir kali Start ditekan
elapsed: 0, // total waktu yang sudah berjalan (ms)
intervalId: null, // referensi interval (untuk clearInterval)
laps: [], // array waktu lap
countdownDuration: 0 // durasi countdown dalam ms
};
// === DOM ELEMENTS ===
const display = document.getElementById('display');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const resetBtn = document.getElementById('resetBtn');
const lapBtn = document.getElementById('lapBtn');
const lapsEl = document.getElementById('laps');
const timerInput = document.getElementById('timerInput');
const tabs = document.querySelectorAll('.tab');
// === FORMAT WAKTU ===
// Kenapa fungsi terpisah? Karena formatting dipanggil 100x per detik.
// Kalau logic-nya nyampur sama DOM update, susah di-maintain.
function formatTime(ms) {
if (ms < 0) ms = 0;
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const centiseconds = Math.floor((ms % 1000) / 10);
// padStart(2, '0') -> tambahin 0 di depan kalau cuma 1 digit
// Kenapa? Biar display konsisten: "01:05:03" bukan "1:5:3"
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(centiseconds).padStart(2, '0')}`;
}
// === UPDATE DISPLAY ===
// Fungsi ini dipanggil setiap 10ms oleh setInterval
function updateDisplay() {
const now = Date.now();
if (state.mode === 'stopwatch') {
// Elapsed = waktu yang sudah lewat sebelumnya + waktu sejak Start terakhir
const current = state.elapsed + (now - state.startTime);
display.textContent = formatTime(current);
} else {
// Countdown: hitung sisa waktu
const remaining = state.countdownDuration - state.elapsed - (now - state.startTime);
display.textContent = formatTime(remaining);
// Warning visual kalau tinggal 10 detik
if (remaining <= 10000) {
display.classList.add('countdown-warning');
}
// Waktu habis!
if (remaining <= 0) {
display.textContent = '00:00:00';
stopTimer();
alert('Waktu habis!');
display.classList.remove('countdown-warning');
}
}
}Kenapa setInterval bukan setTimeout? Untuk timer yang perlu update terus-menerus, setInterval lebih cocok karena kita set sekali dan dia jalan terus. setTimeout harus dipanggil ulang setiap kali (recursive), yang bikin kode lebih ribet. Tapi ingat: kita tetap pakai Date.now() untuk akurasi, bukan mengandalkan interval timing.
Step 4: JavaScript (Controls & Laps)
// === START ===
function startTimer() {
if (state.running) return; // Cegah double-start
// Validasi untuk mode timer
if (state.mode === 'timer' && state.elapsed === 0) {
const mins = parseInt(document.getElementById('minutes').value) || 0;
const secs = parseInt(document.getElementById('seconds').value) || 0;
if (mins === 0 && secs === 0) {
alert('Masukkan durasi timer!');
return;
}
state.countdownDuration = (mins * 60 + secs) * 1000;
}
state.running = true;
state.startTime = Date.now(); // Catat waktu mulai SEKARANG
// Kenapa interval 10ms? Cukup smooth untuk mata manusia (100fps).
// Lebih kecil (1ms) buang resource, lebih besar (100ms) keliatan patah.
state.intervalId = setInterval(updateDisplay, 10);
// Update UI
display.classList.add('running');
startBtn.disabled = true;
stopBtn.disabled = false;
resetBtn.disabled = false;
lapBtn.disabled = false;
}
// === STOP ===
function stopTimer() {
if (!state.running) return;
clearInterval(state.intervalId);
// PENTING: Simpan elapsed SEBELUM reset startTime
// Kenapa? Biar kalau user klik Start lagi, kita bisa LANJUT dari sini
state.elapsed += Date.now() - state.startTime;
state.running = false;
// Update UI
display.classList.remove('running');
startBtn.disabled = false;
stopBtn.disabled = true;
lapBtn.disabled = true;
}
// === RESET ===
function resetTimer() {
clearInterval(state.intervalId);
state.running = false;
state.elapsed = 0;
state.startTime = 0;
state.laps = [];
state.countdownDuration = 0;
// Reset UI
display.textContent = '00:00:00';
display.classList.remove('running', 'countdown-warning');
lapsEl.innerHTML = '';
startBtn.disabled = false;
stopBtn.disabled = true;
resetBtn.disabled = true;
lapBtn.disabled = true;
}
// === LAP ===
function addLap() {
if (!state.running) return;
const currentTime = state.elapsed + (Date.now() - state.startTime);
state.laps.push(currentTime);
const li = document.createElement('li');
li.innerHTML = `<span>Lap ${state.laps.length}</span><span>${formatTime(currentTime)}</span>`;
// Tambah di ATAS list (lap terbaru di atas)
lapsEl.insertBefore(li, lapsEl.firstChild);
}
// === SWITCH MODE ===
function switchMode(mode) {
resetTimer();
state.mode = mode;
tabs.forEach(tab => tab.classList.toggle('active', tab.dataset.mode === mode));
timerInput.style.display = mode === 'timer' ? 'flex' : 'none';
lapBtn.style.display = mode === 'stopwatch' ? 'inline-block' : 'none';
}
// === EVENT LISTENERS ===
startBtn.addEventListener('click', startTimer);
stopBtn.addEventListener('click', stopTimer);
resetBtn.addEventListener('click', resetTimer);
lapBtn.addEventListener('click', addLap);
tabs.forEach(tab => {
tab.addEventListener('click', () => switchMode(tab.dataset.mode));
});Kenapa state.elapsed += Date.now() - state.startTime saat Stop? Ini trik kunci untuk fitur resume. Tanpa ini, setiap kali Start ditekan, timer mulai dari 0. Dengan menyimpan elapsed, kita bisa "lanjut" dari posisi terakhir.
Jebakan Umum
-
Timer drift — Pemula sering bikin
count += 10di setiap interval. Ini SALAH karena interval nggak pernah tepat 10ms. Solusi: selalu hitung dariDate.now() - startTime. -
Lupa clearInterval — Kalau nggak di-clear, interval terus jalan di background. Memory leak! Selalu simpan ID dari
setIntervaldan panggilclearIntervalsaat stop. -
Double start — User klik Start 2x cepat, jadi ada 2 interval jalan bersamaan. Solusi: cek
if (state.running) returndi awal fungsi start. -
Tab inactive = interval lambat — Browser throttle
setIntervaldi tab yang nggak aktif (jadi ~1000ms). Ini kenapa kita pakaiDate.now()bukan counter. Waktu tetap akurat meski interval lambat. -
Format NaN — Kalau lupa handle case
elapsed = 0danstartTime = 0, bisa muncul NaN di display. Selalu validasi input keformatTime.
Upgrade Ideas
- Sound effect — Tambah bunyi "beep" saat countdown selesai pakai
AudioAPI - Dark/Light mode — Toggle tema dengan CSS variables
- Lap comparison — Warnai lap yang lebih cepat/lambat dari rata-rata
- Keyboard shortcut — Space untuk Start/Stop, R untuk Reset
- LocalStorage — Simpan lap times biar nggak hilang saat refresh
Project 6: Bookmark Manager
Kenapa Project Ini?
Bookmark Manager melatih skill CRUD (Create, Read, Update, Delete) yang jadi fondasi hampir semua aplikasi web. Kamu bakal belajar gimana menyimpan data terstruktur di localStorage, validasi form, dan implementasi search/filter.
Di dunia kerja, pattern ini muncul di mana-mana: admin panel, CMS, dashboard. Bedanya cuma backend-nya (API vs localStorage), tapi logic frontend-nya SAMA. Kalau kamu bisa bikin CRUD yang rapi di vanilla JS, transisi ke React/Vue bakal jauh lebih gampang karena kamu paham fundamentalnya.
Skill yang dilatih: localStorage (simpan/ambil/update/hapus), form validation, array manipulation (filter, find, map), search implementation, dan export/import data.
Alur Logika
1. Load halaman
-> Ambil data bookmarks dari localStorage
-> Kalau kosong, pakai array kosong []
-> Render semua bookmark ke DOM
2. User isi form + klik "Tambah"
-> Validasi: URL valid? Nama nggak kosong?
-> Buat object bookmark: { id, name, url, category, createdAt }
-> Push ke array bookmarks
-> Simpan ke localStorage
-> Render ulang list
-> Reset form
3. User klik "Edit" di salah satu bookmark
-> Isi form dengan data bookmark tersebut
-> Ganti tombol "Tambah" jadi "Update"
-> User edit, klik "Update"
-> Cari bookmark by ID di array, update datanya
-> Simpan ke localStorage, Render ulang
4. User klik "Hapus"
-> Konfirmasi dulu (confirm dialog)
-> Filter array: buang yang ID-nya cocok
-> Simpan ke localStorage, Render ulang
5. User ketik di search box
-> Filter array berdasarkan nama/URL yang mengandung keyword
-> Render hasil filter (TANPA mengubah data asli)
6. Export: Convert array ke JSON string, download sebagai file
7. Import: User upload file JSON, parse, replace/merge data
Kenapa pakai array of objects? Karena setiap bookmark punya banyak properti (nama, URL, kategori, tanggal). Array of objects adalah cara paling natural untuk menyimpan koleksi data terstruktur. Ini juga pattern yang sama dengan data dari API (JSON response biasanya array of objects).
Kenapa ID pakai Date.now()? Karena kita butuh identifier unik untuk setiap bookmark. Date.now() menghasilkan angka unik (timestamp milidetik). Untuk app sederhana ini cukup. Di production, biasanya pakai UUID atau ID dari database.
Fitur yang Akan Dibuat
- Tambah bookmark (nama, URL, kategori)
- Edit bookmark yang sudah ada
- Hapus bookmark dengan konfirmasi
- Search/filter by nama atau URL
- Filter by kategori
- Export ke JSON file
- Import dari JSON file
- Validasi URL
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookmark Manager</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Bookmark Manager</h1>
<!-- Form tambah/edit -->
<form id="bookmarkForm" class="form">
<input type="text" id="nameInput" placeholder="Nama bookmark" required>
<input type="url" id="urlInput" placeholder="https://contoh.com" required>
<select id="categoryInput">
<option value="general">General</option>
<option value="tutorial">Tutorial</option>
<option value="tools">Tools</option>
<option value="reference">Reference</option>
<option value="social">Social Media</option>
</select>
<button type="submit" id="submitBtn">+ Tambah</button>
</form>
<!-- Search & Filter -->
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Cari bookmark...">
<select id="filterCategory">
<option value="all">Semua Kategori</option>
<option value="general">General</option>
<option value="tutorial">Tutorial</option>
<option value="tools">Tools</option>
<option value="reference">Reference</option>
<option value="social">Social Media</option>
</select>
</div>
<!-- Import/Export -->
<div class="actions">
<button id="exportBtn" class="btn-action">Export JSON</button>
<label class="btn-action" for="importFile">Import JSON</label>
<input type="file" id="importFile" accept=".json" hidden>
</div>
<!-- Bookmark list -->
<div id="bookmarkList" class="bookmark-list"></div>
<p class="counter" id="counter">0 bookmark tersimpan</p>
</div>
<script src="script.js"></script>
</body>
</html>Kenapa type="url" di input? Browser otomatis validasi format URL. User nggak bisa submit "abc" karena browser akan kasih error. Ini layer pertama validasi (kita tetap validasi lagi di JS untuk keamanan).
Step 2: CSS Styling
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #f0f2f5;
padding: 2rem 1rem;
min-height: 100vh;
}
.container { max-width: 600px; margin: 0 auto; }
h1 { text-align: center; margin-bottom: 1.5rem; color: #1a1a2e; }
.form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
background: #fff;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
margin-bottom: 1rem;
}
.form input[type="text"],
.form input[type="url"] {
grid-column: 1 / -1;
padding: 0.7rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 0.95rem;
transition: border-color 0.2s;
}
.form input:focus { outline: none; border-color: #4361ee; }
.form select {
padding: 0.7rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 0.9rem;
background: #fff;
}
.form button {
padding: 0.7rem;
background: #4361ee;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.form button:hover { background: #3a56d4; }
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.search-bar input {
flex: 1;
padding: 0.7rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 0.9rem;
}
.search-bar select {
padding: 0.7rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
background: #fff;
}
.actions { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.btn-action {
padding: 0.5rem 1rem;
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
transition: border-color 0.2s;
}
.btn-action:hover { border-color: #4361ee; }
.bookmark-list { display: flex; flex-direction: column; gap: 0.5rem; }
.bookmark-item {
background: #fff;
padding: 1rem 1.2rem;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.1s;
}
.bookmark-item:hover { transform: translateX(4px); }
.bookmark-info { flex: 1; min-width: 0; }
.bookmark-info h3 { font-size: 0.95rem; margin-bottom: 0.2rem; color: #1a1a2e; }
.bookmark-info a { font-size: 0.8rem; color: #4361ee; text-decoration: none; word-break: break-all; }
.bookmark-info .category-tag {
display: inline-block;
font-size: 0.7rem;
background: #e8eaff;
color: #4361ee;
padding: 0.15rem 0.5rem;
border-radius: 4px;
margin-top: 0.3rem;
}
.bookmark-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0.3rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.bookmark-actions button:hover { opacity: 1; }
.counter { text-align: center; margin-top: 1rem; color: #888; font-size: 0.85rem; }Step 3: JavaScript (Data Layer)
// === DATA LAYER ===
// Kenapa pisah "data layer" dari "UI layer"?
// Biar logic data (simpan, ambil, filter) nggak nyampur sama DOM manipulation.
// Ini pattern yang sama kayak di React (state vs render).
function getBookmarks() {
const data = localStorage.getItem('bookmarks');
try {
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Data localStorage corrupt:', e);
return [];
}
}
function saveBookmarks(bookmarks) {
// localStorage CUMA bisa simpan string, makanya perlu stringify
localStorage.setItem('bookmarks', JSON.stringify(bookmarks));
}
// Validasi URL
// Kenapa perlu? Cegah javascript: URLs (XSS) dan pastikan link bisa diklik
function isValidURL(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (_) {
return false;
}
}
// Generate ID unik. Date.now() + random = cukup unik untuk app lokal.
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}Step 4: JavaScript (UI Layer & CRUD)
// === STATE ===
let editingId = null; // null = mode tambah, ada isi = mode edit
// === DOM ELEMENTS ===
const form = document.getElementById('bookmarkForm');
const nameInput = document.getElementById('nameInput');
const urlInput = document.getElementById('urlInput');
const categoryInput = document.getElementById('categoryInput');
const submitBtn = document.getElementById('submitBtn');
const searchInput = document.getElementById('searchInput');
const filterCategory = document.getElementById('filterCategory');
const bookmarkList = document.getElementById('bookmarkList');
const counter = document.getElementById('counter');
const exportBtn = document.getElementById('exportBtn');
const importFile = document.getElementById('importFile');
// === RENDER ===
function renderBookmarks() {
let bookmarks = getBookmarks();
// Apply search filter
const searchTerm = searchInput.value.toLowerCase().trim();
if (searchTerm) {
bookmarks = bookmarks.filter(b =>
b.name.toLowerCase().includes(searchTerm) ||
b.url.toLowerCase().includes(searchTerm)
);
}
// Apply category filter
const categoryFilter = filterCategory.value;
if (categoryFilter !== 'all') {
bookmarks = bookmarks.filter(b => b.category === categoryFilter);
}
bookmarkList.innerHTML = bookmarks.map(b => `
<div class="bookmark-item" data-id="${b.id}">
<div class="bookmark-info">
<h3>${escapeHTML(b.name)}</h3>
<a href="${escapeHTML(b.url)}" target="_blank" rel="noopener">${escapeHTML(b.url)}</a>
<div><span class="category-tag">${b.category}</span></div>
</div>
<div class="bookmark-actions">
<button class="edit-btn" title="Edit">✎</button>
<button class="delete-btn" title="Hapus">🗑</button>
</div>
</div>
`).join('');
const total = getBookmarks().length;
counter.textContent = `${total} bookmark tersimpan`;
}
// Escape HTML untuk cegah XSS
// Kalau user masukin <script> sebagai nama, tanpa escape itu JALAN di browser
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// === FORM SUBMIT ===
form.addEventListener('submit', function(e) {
e.preventDefault();
const name = nameInput.value.trim();
const url = urlInput.value.trim();
const category = categoryInput.value;
if (!name) { alert('Nama bookmark nggak boleh kosong!'); return; }
if (!isValidURL(url)) { alert('URL nggak valid! Pastikan diawali https://'); return; }
const bookmarks = getBookmarks();
if (editingId) {
// MODE EDIT
const index = bookmarks.findIndex(b => b.id === editingId);
if (index !== -1) {
bookmarks[index].name = name;
bookmarks[index].url = url;
bookmarks[index].category = category;
}
editingId = null;
submitBtn.textContent = '+ Tambah';
} else {
// MODE TAMBAH
bookmarks.push({
id: generateId(),
name,
url,
category,
createdAt: new Date().toISOString()
});
}
saveBookmarks(bookmarks);
renderBookmarks();
form.reset();
});
// === EVENT DELEGATION untuk Edit & Delete ===
// Kenapa delegation? Karena items di-render ulang terus (innerHTML).
// Dengan delegation, 1 listener di parent cukup untuk semua child.
bookmarkList.addEventListener('click', function(e) {
const item = e.target.closest('.bookmark-item');
if (!item) return;
const id = item.dataset.id;
if (e.target.closest('.delete-btn')) {
if (confirm('Yakin mau hapus bookmark ini?')) {
let bookmarks = getBookmarks();
bookmarks = bookmarks.filter(b => b.id !== id);
saveBookmarks(bookmarks);
renderBookmarks();
}
}
if (e.target.closest('.edit-btn')) {
const bookmarks = getBookmarks();
const bookmark = bookmarks.find(b => b.id === id);
if (bookmark) {
nameInput.value = bookmark.name;
urlInput.value = bookmark.url;
categoryInput.value = bookmark.category;
editingId = id;
submitBtn.textContent = 'Update';
nameInput.focus();
}
}
});
// === SEARCH & FILTER ===
searchInput.addEventListener('input', renderBookmarks);
filterCategory.addEventListener('change', renderBookmarks);
// === EXPORT ===
exportBtn.addEventListener('click', function() {
const bookmarks = getBookmarks();
const blob = new Blob([JSON.stringify(bookmarks, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bookmarks-export.json';
a.click();
URL.revokeObjectURL(url);
});
// === IMPORT ===
importFile.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(event) {
try {
const imported = JSON.parse(event.target.result);
if (!Array.isArray(imported)) { alert('Format file nggak valid!'); return; }
const existing = getBookmarks();
const existingUrls = new Set(existing.map(b => b.url));
const newBookmarks = imported.filter(b => !existingUrls.has(b.url));
saveBookmarks([...existing, ...newBookmarks]);
renderBookmarks();
alert(`${newBookmarks.length} bookmark baru diimport!`);
} catch (err) {
alert('Gagal parse file JSON!');
}
};
reader.readAsText(file);
e.target.value = '';
});
// === INIT ===
renderBookmarks();Jebakan Umum
-
Lupa
JSON.parse/JSON.stringify— localStorage cuma simpan string. Kalau langsung simpan array, hasilnya jadi"[object Object]". Selalu stringify saat simpan, parse saat ambil. -
XSS via innerHTML — Kalau user input mengandung HTML/script dan kamu langsung masukkan ke innerHTML tanpa escape, itu celah keamanan. Selalu escape user input.
-
Mutasi data saat filter — Pemula sering
bookmarks = bookmarks.filter(...)lalu save. Ini HAPUS data asli! Filter hanya untuk TAMPILAN, jangan save hasil filter. -
Event listener hilang setelah innerHTML — Setiap kali innerHTML di-set ulang, semua event listener di child elements hilang. Solusi: event delegation di parent.
-
Import tanpa validasi — User bisa upload file apa aja. Selalu validasi: apakah JSON valid? Apakah array? Apakah setiap item punya field yang dibutuhkan?
Upgrade Ideas
- Drag & drop reorder — Biar user bisa atur urutan bookmark
- Favicon fetch — Ambil icon website otomatis dari Google Favicon API
- Tags system — Satu bookmark bisa punya multiple tags (bukan cuma 1 kategori)
- Bulk delete — Checkbox + "Hapus yang dipilih"
- Keyboard shortcut — Ctrl+N untuk tambah baru, / untuk focus search
Project 7: Pomodoro Timer
Kenapa Project Ini?
Pomodoro Timer bukan cuma timer biasa. Di sini kamu belajar konsep state machine, yaitu cara mengelola aplikasi yang punya beberapa "mode" berbeda (kerja, istirahat, idle) dengan transisi yang jelas antar mode. Ini pattern yang SANGAT penting di software engineering.
Selain itu, kamu juga belajar Notification API (kirim notifikasi ke user meski tab nggak aktif), Audio API (mainkan suara), dan progress visualization. Semua ini skill yang langsung applicable di project nyata.
Di dunia kerja, state machine muncul di: checkout flow (cart, shipping, payment, done), media player (playing, paused, stopped), form wizard (step 1, step 2, step 3). Kalau kamu paham konsepnya di project kecil ini, kamu bisa apply di mana aja.
Alur Logika
STATE MACHINE:
IDLE --(Start)--> WORK --(25min)--> BREAK
^ ^ |
| | |
+---(Reset)--------+------(5min)-----+
Detail flow:
1. State awal: IDLE
-> Display "25:00", tombol Start aktif
2. User klik Start -> State: WORK
-> Hitung endTime = Date.now() + 25 menit
-> Jalankan interval setiap 1 detik
-> Setiap tick: remaining = endTime - Date.now()
-> Update display + progress bar
3. Remaining <= 0 -> State: BREAK
-> Kirim notification "Istirahat!"
-> Play sound
-> Increment session counter
-> Set endTime = Date.now() + 5 menit
-> Lanjut countdown
4. Break selesai -> State: WORK (otomatis) atau IDLE (manual)
-> Notification "Kembali kerja!"
-> Ulangi cycle
5. User klik Reset kapan aja -> State: IDLE
-> Clear interval, reset display
Kenapa hitung endTime bukan countdown? Sama kayak stopwatch: setInterval nggak reliable timing-nya. Dengan menyimpan endTime (kapan timer HARUS selesai), kita tinggal hitung endTime - Date.now() setiap detik. Mau interval telat 100ms atau 500ms, display tetap akurat.
Kenapa state machine? Tanpa state machine, kode jadi spaghetti penuh if-else. "Kalau lagi kerja dan user klik ini, tapi kalau lagi break dan user klik itu..." Dengan state yang jelas (IDLE/WORK/BREAK), setiap aksi punya behavior yang predictable.
Fitur yang Akan Dibuat
- 25 menit work, 5 menit break (default)
- Custom duration (user bisa atur)
- Notification saat timer selesai
- Sound alert
- Session counter (berapa sesi sudah selesai)
- Progress bar visual (circular)
- Auto-start break setelah work selesai
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pomodoro Timer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Pomodoro Timer</h1>
<!-- Status indicator -->
<div class="status" id="status">SIAP MULAI</div>
<!-- Progress ring (SVG circle) -->
<div class="timer-wrapper">
<svg class="progress-ring" width="250" height="250">
<circle class="progress-ring-bg" cx="125" cy="125" r="110"/>
<circle class="progress-ring-fill" id="progressRing" cx="125" cy="125" r="110"/>
</svg>
<div class="timer-display" id="display">25:00</div>
</div>
<!-- Controls -->
<div class="controls">
<button id="startBtn" class="btn btn-start">Start</button>
<button id="pauseBtn" class="btn btn-pause" disabled>Pause</button>
<button id="resetBtn" class="btn btn-reset">Reset</button>
</div>
<!-- Session counter -->
<div class="sessions">
<span>Sesi selesai: </span>
<span id="sessionCount">0</span>
</div>
<!-- Settings -->
<details class="settings">
<summary>Pengaturan</summary>
<div class="settings-content">
<label>
Work (menit):
<input type="number" id="workDuration" value="25" min="1" max="60">
</label>
<label>
Break (menit):
<input type="number" id="breakDuration" value="5" min="1" max="30">
</label>
<label>
<input type="checkbox" id="autoStart" checked>
Auto-start break
</label>
<label>
<input type="checkbox" id="soundEnabled" checked>
Sound alert
</label>
</div>
</details>
</div>
<script src="script.js"></script>
</body>
</html>Kenapa pakai SVG circle untuk progress? Karena CSS stroke-dashoffset pada circle bisa dianimasikan smooth, dan hasilnya circular progress bar yang cantik. Lebih elegan dari progress bar kotak biasa, dan cocok untuk timer.
Step 2: CSS Styling
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.5s;
background: #2d3436;
}
body.work-mode { background: #d63031; }
body.break-mode { background: #00b894; }
.container { text-align: center; padding: 2rem; color: #fff; }
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
.status {
font-size: 0.9rem;
letter-spacing: 3px;
opacity: 0.8;
margin-bottom: 1.5rem;
font-weight: 600;
}
.timer-wrapper {
position: relative;
width: 250px;
height: 250px;
margin: 0 auto 2rem;
}
.progress-ring { transform: rotate(-90deg); }
.progress-ring-bg { fill: none; stroke: rgba(255,255,255,0.1); stroke-width: 8; }
.progress-ring-fill {
fill: none;
stroke: #fff;
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 691;
stroke-dashoffset: 0;
transition: stroke-dashoffset 1s linear;
}
.timer-display {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 3.5rem;
font-family: 'Courier New', monospace;
font-weight: bold;
}
.controls { display: flex; gap: 0.8rem; justify-content: center; margin-bottom: 1.5rem; }
.btn {
padding: 0.8rem 1.5rem;
border: 2px solid rgba(255,255,255,0.3);
background: rgba(255,255,255,0.1);
color: #fff;
border-radius: 10px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
}
.btn:hover:not(:disabled) { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.6); }
.btn:disabled { opacity: 0.3; cursor: not-allowed; }
.sessions { font-size: 1rem; opacity: 0.8; margin-bottom: 1.5rem; }
#sessionCount { font-weight: bold; font-size: 1.3rem; }
.settings {
background: rgba(255,255,255,0.1);
border-radius: 10px;
padding: 1rem;
max-width: 300px;
margin: 0 auto;
}
.settings summary { cursor: pointer; font-size: 0.9rem; }
.settings-content {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
text-align: left;
}
.settings-content label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
.settings-content input[type="number"] { width: 60px; padding: 0.3rem; border: none; border-radius: 4px; text-align: center; }Step 3: JavaScript (State Machine & Helpers)
// === STATE MACHINE ===
// Kenapa pakai constant? Biar nggak typo.
// Kalau nulis 'wrk' bukannya 'work', error-nya susah dilacak.
const STATES = { IDLE: 'idle', WORK: 'work', BREAK: 'break' };
const state = {
current: STATES.IDLE,
endTime: 0,
intervalId: null,
sessionCount: 0,
paused: false,
remainingWhenPaused: 0
};
// === DOM ELEMENTS ===
const display = document.getElementById('display');
const statusEl = document.getElementById('status');
const startBtn = document.getElementById('startBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resetBtn = document.getElementById('resetBtn');
const sessionCountEl = document.getElementById('sessionCount');
const progressRing = document.getElementById('progressRing');
// Circumference of SVG circle (2 * PI * radius)
const CIRCUMFERENCE = 2 * Math.PI * 110;
progressRing.style.strokeDasharray = CIRCUMFERENCE;
function getSettings() {
return {
workMinutes: parseInt(document.getElementById('workDuration').value) || 25,
breakMinutes: parseInt(document.getElementById('breakDuration').value) || 5,
autoStart: document.getElementById('autoStart').checked,
soundEnabled: document.getElementById('soundEnabled').checked
};
}
function formatTime(ms) {
if (ms < 0) ms = 0;
const totalSeconds = Math.ceil(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
function updateProgress(remaining, total) {
const progress = 1 - (remaining / total);
const offset = CIRCUMFERENCE * progress;
progressRing.style.strokeDashoffset = offset;
}
// === NOTIFICATION ===
// Kenapa perlu permission? Notification muncul di LUAR browser (system tray).
// Browser nggak boleh spam user tanpa izin.
function requestNotificationPermission() {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}
function sendNotification(title, body) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title, { body });
}
}
// === SOUND ===
// Generate beep tanpa file external pakai Web Audio API
function playSound() {
const settings = getSettings();
if (!settings.soundEnabled) return;
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.3;
oscillator.start();
setTimeout(() => { gainNode.gain.value = 0; }, 200);
setTimeout(() => { gainNode.gain.value = 0.3; }, 400);
setTimeout(() => { gainNode.gain.value = 0; }, 600);
setTimeout(() => { gainNode.gain.value = 0.3; }, 800);
setTimeout(() => { oscillator.stop(); audioCtx.close(); }, 1000);
}Step 4: JavaScript (Timer Controls)
// === TICK ===
function tick() {
const now = Date.now();
const remaining = state.endTime - now;
const settings = getSettings();
const totalDuration = state.current === STATES.WORK
? settings.workMinutes * 60000
: settings.breakMinutes * 60000;
display.textContent = formatTime(remaining);
updateProgress(remaining, totalDuration);
// Timer selesai!
if (remaining <= 0) {
clearInterval(state.intervalId);
playSound();
if (state.current === STATES.WORK) {
state.sessionCount++;
sessionCountEl.textContent = state.sessionCount;
sendNotification('Pomodoro Selesai!', 'Waktunya istirahat');
if (settings.autoStart) {
startBreak();
} else {
transitionTo(STATES.IDLE);
}
} else if (state.current === STATES.BREAK) {
sendNotification('Break Selesai!', 'Yuk kembali fokus');
transitionTo(STATES.IDLE);
}
}
}
// === TRANSITION STATE ===
// Kenapa fungsi terpisah? Biar setiap transisi PASTI update UI dengan benar.
// Nggak ada state yang "setengah-setengah".
function transitionTo(newState) {
state.current = newState;
document.body.className = '';
if (newState === STATES.WORK) document.body.classList.add('work-mode');
if (newState === STATES.BREAK) document.body.classList.add('break-mode');
const statusText = {
[STATES.IDLE]: 'SIAP MULAI',
[STATES.WORK]: 'FOKUS KERJA',
[STATES.BREAK]: 'ISTIRAHAT'
};
statusEl.textContent = statusText[newState];
if (newState === STATES.IDLE) {
const settings = getSettings();
display.textContent = `${String(settings.workMinutes).padStart(2, '0')}:00`;
progressRing.style.strokeDashoffset = 0;
startBtn.disabled = false;
pauseBtn.disabled = true;
state.paused = false;
}
}
function startWork() {
const settings = getSettings();
state.endTime = Date.now() + settings.workMinutes * 60000;
state.intervalId = setInterval(tick, 100);
transitionTo(STATES.WORK);
startBtn.disabled = true;
pauseBtn.disabled = false;
}
function startBreak() {
const settings = getSettings();
state.endTime = Date.now() + settings.breakMinutes * 60000;
state.intervalId = setInterval(tick, 100);
transitionTo(STATES.BREAK);
startBtn.disabled = true;
pauseBtn.disabled = false;
}
function togglePause() {
if (!state.paused) {
clearInterval(state.intervalId);
state.remainingWhenPaused = state.endTime - Date.now();
state.paused = true;
pauseBtn.textContent = 'Resume';
} else {
state.endTime = Date.now() + state.remainingWhenPaused;
state.intervalId = setInterval(tick, 100);
state.paused = false;
pauseBtn.textContent = 'Pause';
}
}
function resetAll() {
clearInterval(state.intervalId);
state.endTime = 0;
state.paused = false;
pauseBtn.textContent = 'Pause';
transitionTo(STATES.IDLE);
}
// === EVENT LISTENERS ===
startBtn.addEventListener('click', startWork);
pauseBtn.addEventListener('click', togglePause);
resetBtn.addEventListener('click', resetAll);
requestNotificationPermission();
transitionTo(STATES.IDLE);Jebakan Umum
-
Notification nggak muncul — User harus GRANT permission dulu. Di beberapa browser, notification nggak jalan di
file://protocol (harus serve via localhost). Selalu cekNotification.permissionsebelum kirim. -
AudioContext error — Browser modern butuh user interaction (klik) sebelum boleh play audio. Kalau coba play sound tanpa user gesture, bakal di-block. Solusi: buat AudioContext di dalam event handler.
-
Timer nggak akurat di background tab — Browser throttle interval di tab inactive. Tapi karena kita pakai
endTime, display tetap benar saat user balik ke tab. Notification tetap jalan karena dia system-level. -
State inconsistency — Kalau nggak pakai fungsi
transitionToyang terpusat, gampang banget lupa update salah satu elemen UI. Selalu centralize state transitions. -
Memory leak dari setInterval — Kalau user close tab tanpa klik Reset, interval tetap jalan. Untuk app sederhana ini nggak masalah (browser bersihkan saat tab close), tapi di SPA ini bisa jadi masalah. Biasakan
clearIntervaldi cleanup.
Upgrade Ideas
- Long break — Setiap 4 sesi, kasih break lebih panjang (15-20 menit)
- Task list — Tulis task yang mau dikerjakan per sesi pomodoro
- Statistics — Grafik berapa sesi per hari/minggu (simpan di localStorage)
- Theme customization — User pilih warna untuk work/break mode
- Focus mode — Fullscreen + hide distractions
Project 8: Image Gallery / Lightbox
Kenapa Project Ini?
Image gallery melatih skill yang sangat visual dan interactive: event delegation, keyboard navigation, CSS transitions, dan lazy loading. Ini komponen yang ada di hampir SETIAP website (portfolio, e-commerce, blog, social media).
Yang bikin project ini valuable bukan cuma "tampilkan gambar", tapi bagaimana kamu handle performa (lazy load supaya halaman nggak lambat), accessibility (keyboard navigation biar bisa dipakai tanpa mouse), dan UX (smooth transitions biar nggak kaku).
Skill yang dilatih: event delegation (1 listener untuk banyak element), keyboard events (ArrowLeft, ArrowRight, Escape), IntersectionObserver (lazy loading modern), CSS transitions/transforms, dan preloading strategy.
Alur Logika
1. Load halaman
-> Render grid gambar (thumbnail)
-> Gambar pakai lazy loading (belum load sampai masuk viewport)
-> IntersectionObserver watch setiap <img>
2. Gambar masuk viewport (scroll)
-> Observer trigger -> set src dari data-src
-> Gambar mulai loading -> tampilkan dengan fade-in
3. User klik gambar
-> Buka lightbox (overlay fullscreen)
-> Tampilkan gambar full-size
-> Preload gambar sebelah kiri & kanan (biar navigasi smooth)
4. Navigasi di lightbox:
-> Klik panah kanan / tekan ArrowRight = gambar berikutnya
-> Klik panah kiri / tekan ArrowLeft = gambar sebelumnya
-> Klik X / tekan Escape = tutup lightbox
-> Klik area gelap di luar gambar = tutup lightbox
5. Tutup lightbox
-> Sembunyikan overlay
-> Kembalikan scroll ke body
Kenapa event delegation bukan addEventListener per image? Kalau punya 50 gambar dan pasang 50 listener, itu 50 function di memory. Dengan delegation, cukup 1 listener di parent container. Bonus: kalau nanti gambar ditambah dinamis, nggak perlu pasang listener baru.
Kenapa IntersectionObserver untuk lazy load? Cara lama (listen scroll event + hitung posisi) itu berat karena scroll event fires ratusan kali per detik. IntersectionObserver dijalankan oleh browser secara efisien di background thread, jadi nggak blocking main thread.
Fitur yang Akan Dibuat
- Grid layout responsive
- Lazy loading dengan IntersectionObserver
- Lightbox overlay saat klik gambar
- Navigasi panah (klik + keyboard)
- Keyboard support (ArrowLeft, ArrowRight, Escape)
- Preload adjacent images
- Smooth CSS transitions
- Close saat klik di luar gambar
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Gallery</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Image Gallery</h1>
<p class="subtitle">Klik gambar untuk memperbesar. Navigasi pakai panah atau klik.</p>
<!-- Gallery grid -->
<div class="gallery" id="gallery">
<div class="gallery-item" data-index="0">
<img data-src="https://picsum.photos/600/400?random=1" alt="Photo 1" class="lazy">
</div>
<div class="gallery-item" data-index="1">
<img data-src="https://picsum.photos/600/400?random=2" alt="Photo 2" class="lazy">
</div>
<div class="gallery-item" data-index="2">
<img data-src="https://picsum.photos/600/400?random=3" alt="Photo 3" class="lazy">
</div>
<div class="gallery-item" data-index="3">
<img data-src="https://picsum.photos/600/400?random=4" alt="Photo 4" class="lazy">
</div>
<div class="gallery-item" data-index="4">
<img data-src="https://picsum.photos/600/400?random=5" alt="Photo 5" class="lazy">
</div>
<div class="gallery-item" data-index="5">
<img data-src="https://picsum.photos/600/400?random=6" alt="Photo 6" class="lazy">
</div>
<div class="gallery-item" data-index="6">
<img data-src="https://picsum.photos/600/400?random=7" alt="Photo 7" class="lazy">
</div>
<div class="gallery-item" data-index="7">
<img data-src="https://picsum.photos/600/400?random=8" alt="Photo 8" class="lazy">
</div>
<div class="gallery-item" data-index="8">
<img data-src="https://picsum.photos/600/400?random=9" alt="Photo 9" class="lazy">
</div>
<div class="gallery-item" data-index="9">
<img data-src="https://picsum.photos/600/400?random=10" alt="Photo 10" class="lazy">
</div>
<div class="gallery-item" data-index="10">
<img data-src="https://picsum.photos/600/400?random=11" alt="Photo 11" class="lazy">
</div>
<div class="gallery-item" data-index="11">
<img data-src="https://picsum.photos/600/400?random=12" alt="Photo 12" class="lazy">
</div>
</div>
</div>
<!-- Lightbox overlay -->
<div class="lightbox" id="lightbox">
<button class="lightbox-close" id="lightboxClose">×</button>
<button class="lightbox-nav lightbox-prev" id="lightboxPrev">‹</button>
<button class="lightbox-nav lightbox-next" id="lightboxNext">›</button>
<div class="lightbox-content">
<img id="lightboxImg" src="" alt="">
<p class="lightbox-caption" id="lightboxCaption"></p>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Kenapa data-src bukan langsung src? Kalau pakai src, browser langsung download SEMUA gambar saat halaman load (meski gambar di bawah belum kelihatan). Dengan data-src, gambar baru di-load saat masuk viewport. Hemat bandwidth, halaman load lebih cepat.
Step 2: CSS Styling
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #1a1a1a;
color: #fff;
padding: 2rem 1rem;
}
.container { max-width: 1000px; margin: 0 auto; }
h1 { text-align: center; margin-bottom: 0.5rem; }
.subtitle { text-align: center; color: #888; margin-bottom: 2rem; font-size: 0.9rem; }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.gallery-item {
border-radius: 10px;
overflow: hidden;
cursor: pointer;
aspect-ratio: 3/2;
background: #2a2a2a;
position: relative;
transition: transform 0.2s;
}
.gallery-item:hover { transform: scale(1.03); }
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.5s;
}
.gallery-item img.loaded { opacity: 1; }
/* === LIGHTBOX === */
.lightbox {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}
.lightbox.active { opacity: 1; visibility: visible; }
.lightbox-content { max-width: 90vw; max-height: 85vh; text-align: center; }
.lightbox-content img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border-radius: 4px;
transition: transform 0.3s, opacity 0.3s;
}
.lightbox-caption { margin-top: 1rem; color: #aaa; font-size: 0.9rem; }
.lightbox-close {
position: absolute;
top: 1.5rem; right: 1.5rem;
background: none; border: none;
color: #fff; font-size: 2rem;
cursor: pointer; opacity: 0.7;
transition: opacity 0.2s; z-index: 10;
}
.lightbox-close:hover { opacity: 1; }
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255,255,255,0.1);
border: none; color: #fff;
font-size: 3rem;
padding: 1rem 1.5rem;
cursor: pointer; border-radius: 8px;
opacity: 0.6;
transition: opacity 0.2s, background 0.2s;
}
.lightbox-nav:hover { opacity: 1; background: rgba(255,255,255,0.2); }
.lightbox-prev { left: 1rem; }
.lightbox-next { right: 1rem; }
@media (max-width: 600px) {
.gallery { grid-template-columns: repeat(2, 1fr); gap: 0.5rem; }
.lightbox-nav { font-size: 2rem; padding: 0.5rem 1rem; }
}Step 3: JavaScript (Lazy Loading)
// === LAZY LOADING dengan IntersectionObserver ===
// Cara lama: addEventListener('scroll', ...) fires 100x/detik, berat.
// IntersectionObserver: browser kasih tau KAPAN element masuk viewport.
// Efisien, nggak blocking main thread.
function setupLazyLoading() {
const lazyImages = document.querySelectorAll('img.lazy');
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// Pindahkan data-src ke src (mulai loading)
img.src = img.dataset.src;
img.addEventListener('load', () => {
img.classList.add('loaded');
});
img.addEventListener('error', () => {
img.alt = 'Gagal memuat gambar';
img.classList.add('loaded');
});
// Stop observing (sudah di-load)
obs.unobserve(img);
}
});
}, {
// Load gambar 100px SEBELUM masuk viewport
// Biar pas user scroll, gambar sudah siap
rootMargin: '100px'
});
lazyImages.forEach(img => observer.observe(img));
}Step 4: JavaScript (Lightbox)
// === LIGHTBOX STATE ===
let currentIndex = 0;
const galleryItems = document.querySelectorAll('.gallery-item');
const totalImages = galleryItems.length;
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightboxImg');
const lightboxCaption = document.getElementById('lightboxCaption');
const lightboxClose = document.getElementById('lightboxClose');
const lightboxPrev = document.getElementById('lightboxPrev');
const lightboxNext = document.getElementById('lightboxNext');
const gallery = document.getElementById('gallery');
function openLightbox(index) {
currentIndex = index;
const img = galleryItems[index].querySelector('img');
const src = img.dataset.src || img.src;
lightboxImg.src = src;
lightboxCaption.textContent = `${index + 1} / ${totalImages}`;
lightbox.classList.add('active');
document.body.style.overflow = 'hidden'; // Cegah scroll background
preloadAdjacent(index);
}
function closeLightbox() {
lightbox.classList.remove('active');
document.body.style.overflow = '';
}
function navigate(direction) {
currentIndex += direction;
// Wrap around
if (currentIndex >= totalImages) currentIndex = 0;
if (currentIndex < 0) currentIndex = totalImages - 1;
const img = galleryItems[currentIndex].querySelector('img');
const src = img.dataset.src || img.src;
// Animasi transisi
lightboxImg.style.transform = `translateX(${direction * -30}px)`;
lightboxImg.style.opacity = '0';
setTimeout(() => {
lightboxImg.src = src;
lightboxCaption.textContent = `${currentIndex + 1} / ${totalImages}`;
lightboxImg.style.transform = 'translateX(0)';
lightboxImg.style.opacity = '1';
}, 150);
preloadAdjacent(currentIndex);
}
// Kenapa preload? Biar saat user klik next/prev, gambar SUDAH di-cache.
function preloadAdjacent(index) {
const indices = [index - 1, index + 1];
indices.forEach(i => {
const wrappedIndex = (i + totalImages) % totalImages;
const img = galleryItems[wrappedIndex].querySelector('img');
const src = img.dataset.src || img.src;
const preloadImg = new Image();
preloadImg.src = src;
});
}
// === EVENT LISTENERS ===
// Event delegation: 1 listener untuk semua gambar
gallery.addEventListener('click', function(e) {
const item = e.target.closest('.gallery-item');
if (!item) return;
const index = parseInt(item.dataset.index);
openLightbox(index);
});
lightboxClose.addEventListener('click', closeLightbox);
lightboxPrev.addEventListener('click', () => navigate(-1));
lightboxNext.addEventListener('click', () => navigate(1));
// Klik area gelap = tutup
lightbox.addEventListener('click', function(e) {
if (e.target === lightbox) closeLightbox();
});
// Keyboard navigation (accessibility!)
document.addEventListener('keydown', function(e) {
if (!lightbox.classList.contains('active')) return;
switch (e.key) {
case 'ArrowRight': navigate(1); break;
case 'ArrowLeft': navigate(-1); break;
case 'Escape': closeLightbox(); break;
}
});
// === INIT ===
setupLazyLoading();Jebakan Umum
-
Scroll masih jalan di belakang lightbox — Kalau nggak set
body.overflow = 'hidden', user bisa scroll halaman di belakang overlay. Jangan lupa kembalikan saat close. -
Event bubbling di lightbox — Klik gambar di dalam lightbox juga trigger click di lightbox parent (yang harusnya close). Solusi: cek
e.target === lightbox(bukan child-nya). -
Lazy load nggak jalan di browser lama — IntersectionObserver nggak ada di IE11. Untuk production, tambahkan polyfill atau fallback.
-
Gambar belum load saat lightbox dibuka — Kalau user klik gambar yang belum lazy-loaded,
img.srcmasih kosong. Solusi: selalu ambil daridata-srcsebagai fallback. -
Memory leak dari preload — Setiap navigasi bikin
new Image(). Untuk gallery kecil nggak masalah, tapi kalau ratusan gambar, pertimbangkan cache limit.
Upgrade Ideas
- Zoom on hover — Magnifier effect saat hover di lightbox
- Slideshow mode — Auto-advance setiap 3 detik
- Touch/swipe support — Swipe kiri/kanan di mobile (touch events)
- Image info panel — Tampilkan metadata, ukuran file, dimensi
- Upload gambar — User bisa tambah gambar sendiri (FileReader + IndexedDB)
Project 9: Real-time Chat (Local)
Kenapa Project Ini?
Chat app melatih skill yang fundamental untuk web modern: real-time communication, DOM manipulation yang efisien, dan input handling. Meski kita simulasi pakai BroadcastChannel (bukan WebSocket sungguhan), konsep dan pattern-nya SAMA.
Di dunia kerja, real-time features ada di mana-mana: live chat support, collaborative editing (Google Docs), notification systems, live dashboards. Memahami gimana data mengalir antar "client" dan gimana UI di-update secara real-time itu skill yang sangat dicari.
Skill yang dilatih: BroadcastChannel API (komunikasi antar tab), DOM manipulation (append messages), scroll behavior, input sanitization (cegah XSS), dan event handling (keypress, focus/blur).
Alur Logika
SETUP:
1. User buka halaman -> minta username
2. Buat BroadcastChannel('chat-room')
3. Kirim pesan "join" ke channel (biar tab lain tau ada user baru)
KIRIM PESAN:
1. User ketik pesan + tekan Enter (atau klik Send)
2. Validasi: pesan nggak kosong? Sudah di-trim?
3. Sanitize input (escape HTML)
4. Buat object message: { user, text, timestamp, type: 'message' }
5. Tampilkan di chat sendiri (sebagai "sent")
6. Broadcast ke channel (tab lain akan terima)
TERIMA PESAN:
1. BroadcastChannel.onmessage fires
2. Cek type: 'message', 'join', 'typing'
3. Kalau 'message' -> tampilkan di chat (sebagai "received")
4. Kalau 'typing' -> tampilkan "User sedang mengetik..."
5. Auto-scroll ke bawah
TYPING INDICATOR:
1. User mulai ketik -> broadcast { type: 'typing', user, isTyping: true }
2. Debounce: kalau 2 detik nggak ketik -> broadcast { isTyping: false }
3. Tab lain terima -> tampilkan/sembunyikan indicator
Kenapa BroadcastChannel untuk simulasi? WebSocket butuh server (backend). Kita fokus frontend dulu. BroadcastChannel = komunikasi antar tab di browser YANG SAMA. Pattern-nya mirip: send message, listen for messages. Buka 2 tab = 2 "user" yang bisa chat satu sama lain.
Kenapa sanitize input? Kalau user ketik <script>alert('hacked')</script> dan kamu langsung masukkan ke innerHTML, script itu JALAN. Ini namanya XSS (Cross-Site Scripting). Selalu escape HTML entities sebelum render user input.
Kenapa scrollIntoView? Chat yang baik otomatis scroll ke pesan terbaru. Tapi HANYA kalau user memang sudah di bawah. Kalau user lagi scroll ke atas baca pesan lama, jangan paksa scroll ke bawah (annoying).
Fitur yang Akan Dibuat
- Input username
- Kirim dan terima pesan (antar tab)
- Timestamp di setiap pesan
- Typing indicator ("sedang mengetik...")
- Auto-scroll ke pesan terbaru
- Emoji picker sederhana
- Join/leave notification
- Sanitize input (anti XSS)
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Room</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Username modal -->
<div class="modal" id="usernameModal">
<div class="modal-content">
<h2>Masuk Chat Room</h2>
<p>Buka 2 tab untuk simulasi chat!</p>
<input type="text" id="usernameInput" placeholder="Nama kamu..." maxlength="20">
<button id="joinBtn">Masuk</button>
</div>
</div>
<!-- Chat container -->
<div class="chat-container" id="chatContainer" style="display: none;">
<div class="chat-header">
<h2>Chat Room</h2>
<span class="username-display" id="usernameDisplay"></span>
</div>
<!-- Messages area -->
<div class="messages" id="messages"></div>
<!-- Typing indicator -->
<div class="typing-indicator" id="typingIndicator" style="display: none;">
<span id="typingUser"></span> sedang mengetik...
</div>
<!-- Input area -->
<div class="input-area">
<button class="emoji-btn" id="emojiBtn">☺</button>
<input type="text" id="messageInput" placeholder="Ketik pesan..." maxlength="500">
<button id="sendBtn">Kirim</button>
</div>
<!-- Emoji picker -->
<div class="emoji-picker" id="emojiPicker" style="display: none;">
<span class="emoji" data-emoji="😀">😀</span>
<span class="emoji" data-emoji="😂">😂</span>
<span class="emoji" data-emoji="😍">😍</span>
<span class="emoji" data-emoji="😭">😭</span>
<span class="emoji" data-emoji="🔥">🔥</span>
<span class="emoji" data-emoji="👍">👍</span>
<span class="emoji" data-emoji="👏">👏</span>
<span class="emoji" data-emoji="🚀">🚀</span>
<span class="emoji" data-emoji="❤">❤</span>
<span class="emoji" data-emoji="⭐">⭐</span>
<span class="emoji" data-emoji="🎉">🎉</span>
<span class="emoji" data-emoji="✌">✌</span>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Step 2: CSS Styling
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #0a0a0a;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
/* === MODAL === */
.modal {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
background: #1e1e1e;
padding: 2rem;
border-radius: 12px;
text-align: center;
width: 90%;
max-width: 350px;
}
.modal-content h2 { margin-bottom: 0.5rem; }
.modal-content p { color: #888; margin-bottom: 1.5rem; font-size: 0.9rem; }
.modal-content input {
width: 100%;
padding: 0.8rem 1rem;
border: 2px solid #333;
border-radius: 8px;
background: #0a0a0a;
color: #fff;
font-size: 1rem;
margin-bottom: 1rem;
}
.modal-content input:focus { outline: none; border-color: #4361ee; }
.modal-content button {
width: 100%;
padding: 0.8rem;
background: #4361ee;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
/* === CHAT === */
.chat-container {
width: 100%;
max-width: 500px;
height: 90vh;
display: flex;
flex-direction: column;
background: #1e1e1e;
border-radius: 12px;
overflow: hidden;
margin: 1rem;
}
.chat-header {
padding: 1rem 1.5rem;
background: #2a2a2a;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h2 { font-size: 1.1rem; }
.username-display { color: #4361ee; font-size: 0.85rem; }
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message {
max-width: 75%;
padding: 0.6rem 1rem;
border-radius: 12px;
font-size: 0.9rem;
word-wrap: break-word;
}
.message.sent {
align-self: flex-end;
background: #4361ee;
border-bottom-right-radius: 4px;
}
.message.received {
align-self: flex-start;
background: #333;
border-bottom-left-radius: 4px;
}
.message .sender {
font-size: 0.75rem;
opacity: 0.7;
margin-bottom: 0.2rem;
font-weight: 600;
}
.message .time {
font-size: 0.7rem;
opacity: 0.5;
margin-top: 0.3rem;
text-align: right;
}
.message.system {
align-self: center;
background: none;
color: #888;
font-size: 0.8rem;
font-style: italic;
padding: 0.3rem;
}
.typing-indicator {
padding: 0.3rem 1.5rem;
font-size: 0.8rem;
color: #888;
font-style: italic;
}
.input-area {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: #2a2a2a;
}
.input-area input {
flex: 1;
padding: 0.7rem 1rem;
border: none;
border-radius: 8px;
background: #0a0a0a;
color: #fff;
font-size: 0.95rem;
}
.input-area input:focus { outline: none; }
.input-area button, .emoji-btn {
padding: 0.7rem 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
}
#sendBtn { background: #4361ee; color: #fff; }
.emoji-btn { background: #333; font-size: 1.2rem; }
.emoji-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.8rem 1rem;
background: #2a2a2a;
border-top: 1px solid #333;
}
.emoji {
font-size: 1.5rem;
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
transition: background 0.1s;
}
.emoji:hover { background: #444; }Step 3: JavaScript (Setup & BroadcastChannel)
// === BROADCAST CHANNEL ===
// Ini "pengganti" WebSocket untuk simulasi lokal.
// Semua tab yang buka channel dengan nama sama bisa saling kirim pesan.
const channel = new BroadcastChannel('chat-room');
let username = '';
let typingTimeout = null;
// === DOM ELEMENTS ===
const usernameModal = document.getElementById('usernameModal');
const usernameInput = document.getElementById('usernameInput');
const joinBtn = document.getElementById('joinBtn');
const chatContainer = document.getElementById('chatContainer');
const usernameDisplay = document.getElementById('usernameDisplay');
const messages = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const typingUser = document.getElementById('typingUser');
const emojiBtn = document.getElementById('emojiBtn');
const emojiPicker = document.getElementById('emojiPicker');
// === JOIN ===
function joinChat() {
const name = usernameInput.value.trim();
if (!name) { alert('Masukkan nama dulu!'); return; }
username = name;
usernameModal.style.display = 'none';
chatContainer.style.display = 'flex';
usernameDisplay.textContent = username;
// Broadcast join message ke tab lain
channel.postMessage({
type: 'join',
user: username,
timestamp: Date.now()
});
addSystemMessage(`Kamu masuk sebagai ${username}`);
messageInput.focus();
}
joinBtn.addEventListener('click', joinChat);
usernameInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') joinChat();
});Step 4: JavaScript (Messaging & Features)
// === SANITIZE INPUT ===
// WAJIB! Cegah XSS. User bisa masukin HTML/script berbahaya.
function sanitize(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// === FORMAT TIMESTAMP ===
function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
}
// === ADD MESSAGE TO DOM ===
function addMessage(data, isSent) {
const div = document.createElement('div');
div.className = `message ${isSent ? 'sent' : 'received'}`;
div.innerHTML = `
${!isSent ? `<div class="sender">${sanitize(data.user)}</div>` : ''}
<div class="text">${sanitize(data.text)}</div>
<div class="time">${formatTime(data.timestamp)}</div>
`;
messages.appendChild(div);
autoScroll();
}
function addSystemMessage(text) {
const div = document.createElement('div');
div.className = 'message system';
div.textContent = text;
messages.appendChild(div);
autoScroll();
}
// === AUTO SCROLL ===
// Kenapa cek posisi dulu? Kalau user lagi scroll ke atas baca pesan lama,
// jangan paksa scroll ke bawah. Itu annoying.
function autoScroll() {
const threshold = 100; // pixel dari bawah
const isNearBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < threshold;
if (isNearBottom) {
messages.scrollTop = messages.scrollHeight;
}
}
// === SEND MESSAGE ===
function sendMessage() {
const text = messageInput.value.trim();
if (!text) return;
const data = {
type: 'message',
user: username,
text: text,
timestamp: Date.now()
};
// Tampilkan di chat sendiri
addMessage(data, true);
// Broadcast ke tab lain
channel.postMessage(data);
// Reset input
messageInput.value = '';
messageInput.focus();
// Stop typing indicator
channel.postMessage({ type: 'typing', user: username, isTyping: false });
}
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') sendMessage();
});
// === TYPING INDICATOR ===
// Debounce: kirim "typing" saat mulai ketik,
// kirim "stop typing" kalau 2 detik nggak ketik lagi.
messageInput.addEventListener('input', function() {
channel.postMessage({ type: 'typing', user: username, isTyping: true });
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
channel.postMessage({ type: 'typing', user: username, isTyping: false });
}, 2000);
});
// === RECEIVE MESSAGES ===
channel.onmessage = function(event) {
const data = event.data;
switch (data.type) {
case 'message':
addMessage(data, false);
break;
case 'join':
addSystemMessage(`${data.user} bergabung ke chat`);
break;
case 'typing':
if (data.isTyping) {
typingUser.textContent = data.user;
typingIndicator.style.display = 'block';
} else {
typingIndicator.style.display = 'none';
}
break;
case 'leave':
addSystemMessage(`${data.user} keluar dari chat`);
break;
}
};
// === EMOJI PICKER ===
emojiBtn.addEventListener('click', function() {
const isVisible = emojiPicker.style.display === 'flex';
emojiPicker.style.display = isVisible ? 'none' : 'flex';
});
emojiPicker.addEventListener('click', function(e) {
if (e.target.classList.contains('emoji')) {
messageInput.value += e.target.dataset.emoji;
messageInput.focus();
}
});
// === LEAVE (saat tab ditutup) ===
window.addEventListener('beforeunload', function() {
channel.postMessage({ type: 'leave', user: username });
});Jebakan Umum
-
XSS via chat message — INI YANG PALING BAHAYA. Kalau nggak sanitize, user bisa inject script lewat pesan chat. SELALU escape HTML sebelum render ke DOM.
-
BroadcastChannel nggak lintas browser — BroadcastChannel cuma bekerja di tab/window yang SAMA browser dan SAMA origin. Nggak bisa lintas device. Untuk itu butuh WebSocket + server.
-
Scroll behavior annoying — Kalau setiap pesan baru langsung scroll ke bawah tanpa cek posisi user, itu mengganggu. Cek dulu apakah user sudah di dekat bawah sebelum auto-scroll.
-
Typing indicator nggak hilang — Kalau user close tab tanpa kirim pesan, typing indicator di tab lain tetap muncul. Solusi: pakai
beforeunloadevent untuk broadcast "stop typing" dan "leave". -
Memory leak dari messages — Kalau chat jalan lama, DOM bisa punya ribuan element. Untuk production, implementasi virtual scrolling atau limit jumlah pesan di DOM (hapus yang paling atas).
Upgrade Ideas
- Message reactions — Klik pesan untuk kasih emoji reaction
- Image sharing — Kirim gambar via FileReader + data URL
- Message history — Simpan di localStorage, load saat join
- Multiple rooms — User bisa buat/join room berbeda
- Read receipts — Tanda centang kalau pesan sudah dibaca
Project 10: Snake Game
Kenapa Project Ini?
Snake Game adalah "rite of passage" buat programmer. Di sini kamu belajar Canvas API (gambar grafik 2D), game loop (konsep fundamental game development), collision detection, dan keyboard input handling. Ini bukan cuma bikin game, tapi belajar cara berpikir tentang animasi dan simulasi.
Canvas API dipakai di banyak tempat selain game: data visualization (chart libraries), image editing, generative art, bahkan PDF rendering. Memahami cara kerja Canvas membuka banyak pintu.
Game loop sendiri adalah pattern yang muncul di animasi web, real-time dashboards, dan interactive visualizations. Konsep "update state, lalu render" itu universal.
Skill yang dilatih: Canvas API (drawRect, clearRect), requestAnimationFrame + timestamp-based game loop, keyboard input (arrow keys), collision detection (wall + self), dan array manipulation (snake body sebagai queue).
Alur Logika
GAME LOOP (jalan terus selama game aktif):
1. requestAnimationFrame(gameLoop)
2. Cek apakah sudah waktunya update (berdasarkan speed)
3. Kalau ya:
a. Hitung posisi head baru berdasarkan direction
b. Cek collision:
- Nabrak dinding? -> Game Over
- Nabrak badan sendiri? -> Game Over
- Kena food? -> Score++, grow snake, spawn food baru
c. Update snake array:
- Tambah head baru di depan (unshift)
- Hapus tail di belakang (pop) -- KECUALI kalau baru makan
d. Render: clear canvas, gambar snake, gambar food, update score
KENAPA ARRAY UNTUK BODY SNAKE?
- Snake = antrian (queue) dari koordinat [x, y]
- Gerak maju = tambah kepala baru di depan, hapus ekor di belakang
- Makan = tambah kepala, JANGAN hapus ekor (snake memanjang)
- Cek nabrak diri = cek apakah head sama dengan salah satu body segment
KENAPA requestAnimationFrame + TIMESTAMP?
- setInterval untuk game loop itu BAD PRACTICE
- rAF sync dengan refresh rate monitor (smooth 60fps)
- Timestamp biar speed konsisten di semua device
- Kalau pakai setInterval, game bisa terlalu cepat/lambat tergantung device
KEYBOARD INPUT:
- Simpan direction saat keydown
- JANGAN langsung gerak (bisa bikin bug: tekan 2 arah sekaligus)
- Apply direction di next tick
- Cegah reverse (kalau jalan kanan, nggak boleh langsung kiri)
Fitur yang Akan Dibuat
- Snake bergerak dengan arrow keys
- Food muncul random di grid
- Score bertambah saat makan food
- Speed meningkat seiring score
- Game over saat nabrak dinding atau badan sendiri
- Restart game
- High score (localStorage)
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake Game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Snake Game</h1>
<div class="game-info">
<span>Score: <strong id="score">0</strong></span>
<span>High Score: <strong id="highScore">0</strong></span>
<span>Speed: <strong id="speed">1</strong></span>
</div>
<!-- Canvas untuk game -->
<canvas id="gameCanvas" width="400" height="400"></canvas>
<!-- Game over overlay -->
<div class="game-over" id="gameOver" style="display: none;">
<h2>Game Over!</h2>
<p>Score: <span id="finalScore">0</span></p>
<button id="restartBtn">Main Lagi</button>
</div>
<p class="instructions">Pakai Arrow Keys untuk gerak. Tekan Space untuk pause.</p>
<button id="startBtn" class="start-btn">Start Game</button>
</div>
<script src="script.js"></script>
</body>
</html>Kenapa Canvas bukan DOM elements? Kalau snake punya 50 segment dan kita pakai <div> per segment, itu 50+ DOM elements yang harus di-update setiap frame. DOM manipulation itu LAMBAT untuk animasi real-time. Canvas cuma 1 element, dan kita gambar langsung ke pixel buffer. Jauh lebih performant untuk game.
Step 2: CSS Styling
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #0a0a0a;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 1.5rem;
}
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
.game-info {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.game-info strong { color: #4ecca3; }
canvas {
background: #1a1a2e;
border: 2px solid #333;
border-radius: 4px;
display: block;
margin: 0 auto;
}
.game-over {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.9);
padding: 2rem 3rem;
border-radius: 12px;
text-align: center;
z-index: 10;
}
.game-over h2 { color: #e94560; margin-bottom: 0.5rem; }
.game-over p { margin-bottom: 1rem; }
.game-over button, .start-btn {
padding: 0.7rem 2rem;
background: #4ecca3;
color: #1a1a2e;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s;
}
.game-over button:hover, .start-btn:hover { transform: scale(1.05); }
.instructions {
margin-top: 1rem;
color: #666;
font-size: 0.85rem;
}
.start-btn { margin-top: 1rem; }
.container { position: relative; }Step 3: JavaScript (Game Setup & Loop)
// === CONSTANTS ===
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Grid-based game: canvas dibagi jadi kotak-kotak
const GRID_SIZE = 20; // Ukuran 1 kotak (pixel)
const GRID_COUNT = canvas.width / GRID_SIZE; // 400/20 = 20 kotak
// === GAME STATE ===
let snake = [];
let food = { x: 0, y: 0 };
let direction = { x: 1, y: 0 }; // Mulai gerak ke kanan
let nextDirection = { x: 1, y: 0 }; // Buffer input
let score = 0;
let highScore = parseInt(localStorage.getItem('snakeHighScore')) || 0;
let gameRunning = false;
let paused = false;
let lastUpdateTime = 0;
let gameSpeed = 150; // ms per update (makin kecil = makin cepat)
// DOM elements
const scoreEl = document.getElementById('score');
const highScoreEl = document.getElementById('highScore');
const speedEl = document.getElementById('speed');
const gameOverEl = document.getElementById('gameOver');
const finalScoreEl = document.getElementById('finalScore');
const startBtn = document.getElementById('startBtn');
const restartBtn = document.getElementById('restartBtn');
// Init high score display
highScoreEl.textContent = highScore;
// === INIT GAME ===
function initGame() {
// Snake mulai di tengah, panjang 3
snake = [
{ x: 10, y: 10 },
{ x: 9, y: 10 },
{ x: 8, y: 10 }
];
direction = { x: 1, y: 0 };
nextDirection = { x: 1, y: 0 };
score = 0;
gameSpeed = 150;
paused = false;
scoreEl.textContent = '0';
speedEl.textContent = '1';
gameOverEl.style.display = 'none';
spawnFood();
}
// === SPAWN FOOD ===
// Kenapa loop? Karena food nggak boleh muncul di atas snake body.
function spawnFood() {
let newFood;
do {
newFood = {
x: Math.floor(Math.random() * GRID_COUNT),
y: Math.floor(Math.random() * GRID_COUNT)
};
} while (snake.some(segment => segment.x === newFood.x && segment.y === newFood.y));
food = newFood;
}Kenapa grid-based? Daripada pakai koordinat pixel (0-400), kita pakai grid (0-19). Ini bikin collision detection JAUH lebih simpel. Dua object "bertabrakan" kalau mereka di grid yang sama (x dan y sama). Nggak perlu hitung overlap rectangle.
Step 4: JavaScript (Game Loop & Rendering)
// === GAME LOOP ===
// Kenapa requestAnimationFrame bukan setInterval?
// 1. rAF sync dengan monitor refresh (smooth, no tearing)
// 2. Otomatis pause kalau tab inactive (hemat battery)
// 3. Timestamp parameter biar speed konsisten
function gameLoop(currentTime) {
if (!gameRunning) return;
if (paused) {
requestAnimationFrame(gameLoop);
return;
}
// Cek apakah sudah waktunya update
// Kenapa? Biar speed bisa dikontrol. rAF jalan 60fps,
// tapi kita mau snake gerak lebih lambat dari itu.
if (currentTime - lastUpdateTime >= gameSpeed) {
lastUpdateTime = currentTime;
update();
}
render();
requestAnimationFrame(gameLoop);
}
// === UPDATE (Logic) ===
function update() {
// Apply buffered direction
direction = { ...nextDirection };
// Hitung posisi head baru
const head = {
x: snake[0].x + direction.x,
y: snake[0].y + direction.y
};
// === COLLISION DETECTION ===
// Nabrak dinding?
if (head.x < 0 || head.x >= GRID_COUNT || head.y < 0 || head.y >= GRID_COUNT) {
gameOver();
return;
}
// Nabrak badan sendiri?
if (snake.some(segment => segment.x === head.x && segment.y === head.y)) {
gameOver();
return;
}
// Tambah head baru di depan array
snake.unshift(head);
// Cek makan food
if (head.x === food.x && head.y === food.y) {
// Makan! Score naik, JANGAN pop tail (snake memanjang)
score++;
scoreEl.textContent = score;
// Speed naik setiap 5 point
if (score % 5 === 0 && gameSpeed > 50) {
gameSpeed -= 10;
speedEl.textContent = Math.floor((150 - gameSpeed) / 10) + 1;
}
spawnFood();
} else {
// Nggak makan, hapus tail (snake tetap panjangnya sama)
snake.pop();
}
}
// === RENDER (Visual) ===
function render() {
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Gambar grid (optional, biar keliatan kotak-kotaknya)
ctx.strokeStyle = '#1f1f3a';
ctx.lineWidth = 0.5;
for (let i = 0; i < GRID_COUNT; i++) {
ctx.beginPath();
ctx.moveTo(i * GRID_SIZE, 0);
ctx.lineTo(i * GRID_SIZE, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, i * GRID_SIZE);
ctx.lineTo(canvas.width, i * GRID_SIZE);
ctx.stroke();
}
// Gambar snake
snake.forEach((segment, index) => {
if (index === 0) {
// Head: warna lebih terang
ctx.fillStyle = '#4ecca3';
} else {
// Body: gradient dari terang ke gelap
const opacity = 1 - (index / snake.length) * 0.5;
ctx.fillStyle = `rgba(78, 204, 163, ${opacity})`;
}
ctx.fillRect(
segment.x * GRID_SIZE + 1,
segment.y * GRID_SIZE + 1,
GRID_SIZE - 2,
GRID_SIZE - 2
);
// Rounded corners effect
ctx.beginPath();
ctx.roundRect(
segment.x * GRID_SIZE + 1,
segment.y * GRID_SIZE + 1,
GRID_SIZE - 2,
GRID_SIZE - 2,
3
);
ctx.fill();
});
// Gambar food
ctx.fillStyle = '#e94560';
ctx.beginPath();
ctx.arc(
food.x * GRID_SIZE + GRID_SIZE / 2,
food.y * GRID_SIZE + GRID_SIZE / 2,
GRID_SIZE / 2 - 2,
0,
Math.PI * 2
);
ctx.fill();
}
// === GAME OVER ===
function gameOver() {
gameRunning = false;
// Update high score
if (score > highScore) {
highScore = score;
localStorage.setItem('snakeHighScore', highScore);
highScoreEl.textContent = highScore;
}
finalScoreEl.textContent = score;
gameOverEl.style.display = 'block';
}Kenapa snake.unshift(head) lalu snake.pop()? Ini cara paling elegan untuk "gerakkan" snake. Daripada update posisi setiap segment satu-satu (O(n) operations per segment), kita cukup tambah 1 di depan dan hapus 1 di belakang. Hasilnya sama: snake "bergerak maju". Dan kalau makan food, kita skip pop(), jadi snake memanjang 1 segment.
Step 5: JavaScript (Input & Controls)
// === KEYBOARD INPUT ===
// Kenapa pakai nextDirection (buffer)?
// Kalau langsung ubah direction, user bisa tekan 2 tombol dalam 1 frame
// dan bikin snake "balik arah" (instant game over).
// Dengan buffer, direction cuma di-apply sekali per tick.
document.addEventListener('keydown', function(e) {
switch (e.key) {
case 'ArrowUp':
// Cegah reverse: kalau lagi gerak ke bawah, nggak boleh langsung ke atas
if (direction.y !== 1) nextDirection = { x: 0, y: -1 };
e.preventDefault();
break;
case 'ArrowDown':
if (direction.y !== -1) nextDirection = { x: 0, y: 1 };
e.preventDefault();
break;
case 'ArrowLeft':
if (direction.x !== 1) nextDirection = { x: -1, y: 0 };
e.preventDefault();
break;
case 'ArrowRight':
if (direction.x !== -1) nextDirection = { x: 1, y: 0 };
e.preventDefault();
break;
case ' ':
// Space = pause/resume
if (gameRunning) paused = !paused;
e.preventDefault();
break;
}
});
// === START & RESTART ===
function startGame() {
initGame();
gameRunning = true;
startBtn.style.display = 'none';
lastUpdateTime = performance.now();
requestAnimationFrame(gameLoop);
}
startBtn.addEventListener('click', startGame);
restartBtn.addEventListener('click', startGame);
// Render initial state
initGame();
render();Jebakan Umum
-
Snake bisa balik arah instant — Kalau user tekan kanan lalu langsung tekan kiri dalam 1 frame, snake "masuk ke dirinya sendiri" dan langsung game over. Solusi: cek
direction.x !== 1sebelum izinkan gerak kiri. -
Food muncul di atas snake — Kalau random position kebetulan sama dengan posisi snake body, food "nggak kelihatan". Solusi: loop
do...whilesampai dapat posisi yang kosong. -
Game speed nggak konsisten — Kalau pakai
setInterval(update, 100), di device lambat game jadi patah-patah. DenganrequestAnimationFrame+ timestamp check, visual tetap smooth dan logic update tetap konsisten. -
Canvas blur di retina display — Canvas default resolution bisa keliatan blur di layar high-DPI. Untuk fix: set canvas width/height 2x, lalu scale down dengan CSS. Tapi untuk project pemula ini, skip dulu.
-
Memory leak dari requestAnimationFrame — Kalau nggak set
gameRunning = falsesaat game over, loop terus jalan. Selalu ada kondisi exit di awal game loop.
Upgrade Ideas
- Wrap-around mode — Snake keluar kanan, muncul di kiri (tanpa game over)
- Obstacles — Tambah dinding/rintangan di tengah map
- Power-ups — Food spesial yang kasih efek (slow motion, invincible)
- Mobile controls — Swipe detection untuk touch screen
- Multiplayer — 2 snake di 1 canvas (WASD vs Arrow Keys)
Penutup
Selamat! Kalau kamu sudah selesai semua 10 project (dari Todo App sampai Snake Game), kamu sudah punya fondasi yang SANGAT kuat di JavaScript. Skill yang kamu latih di sini, mulai dari DOM manipulation, event handling, state management, sampai Canvas API, itu semua transferable ke framework apapun (React, Vue, Angular).
Tips selanjutnya:
- Kombinasikan project — Misal: Pomodoro Timer + Task List, atau Chat + Emoji Game
- Deploy — Upload ke GitHub Pages atau Netlify biar bisa diakses orang lain
- Baca kode orang — Lihat gimana developer lain solve masalah yang sama
- Refactor — Balik ke project lama, improve kodenya dengan ilmu baru
Keep coding!
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.