Kalendarz w czystym JavaScript – bez jQuery
Dwa lata temu opisałem na blogu budowę kalendarza miesięcznego w HTML, CSS i jQuery – z refaktoryzacją na CSS Grid i nawigacją między miesiącami, a na końcu z tłumaczeniami pobieranymi AJAX-em z API w PHP. Tamta seria jest nadal poprawna i działa, ale powstała w realiach, w których jQuery było odruchem. Dziś ten odruch już się nie broni: wszystko, do czego sięgaliśmy po $, jest od lat wbudowane w przeglądarki. W tym wpisie odtwarzam dokładnie ten sam kalendarz, ale w czystym JavaScript – i przy okazji upraszczam całą trzecią część (PHP + MySQL + AJAX) do jednej linijki.
To nowa wersja, nie poprawka. Starą serię w jQuery (2024) zostawiam pod jej adresami bez zmian – oznaczam ją jako wersję archiwalną i linkuję stąd do niej. Zasada bloga: starych wpisów nie przepisuję w miejscu, modernizację publikuję jako osobny wpis.
Dlaczego bez jQuery
jQuery rozwiązywało realne problemy ery IE: niespójne API DOM, koszmar addEventListener vs attachEvent, brak wygodnego AJAX-a. Dziś każdy z tych problemów zniknął:
| Po co sięgaliśmy po jQuery | Natywny odpowiednik (od lat we wszystkich przeglądarkach) |
|---|---|
$(sel) / $(el).find() | document.querySelector / querySelectorAll |
$('<div>').addClass() | document.createElement + classList, lub innerHTML |
$(el).on('click', …) | el.addEventListener('click', …) + delegacja przez closest() |
$.ajax / $.getJSON | fetch() |
| tablice nazw miesięcy i dni | Intl.DateTimeFormat (lokalizacja w przeglądarce) |
W praktyce oznacza to 0 KB zależności zamiast ~30 KB samego jQuery, brak dodatkowego żądania sieciowego i kod, który zrozumie każda przeglądarka bez warstwy pośredniej.
Szkielet HTML
Pierwsza różnica widać już w <head> – znika <script> ładujący jQuery z CDN. Zostaje sam moduł:
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<title>Kalendarz</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="calendar.js"></script>
</head>
<body>
<month-calendar locale="pl-PL"></month-calendar>
</body>
</html>Zamiast pustego <div id="calendar">, który skrypt wypełni z zewnątrz, użyjemy własnego elementu <month-calendar> – Web Componentu. To natywny mechanizm przeglądarki: definiujemy własny tag, który sam wie, jak się wyrenderować i jak reagować na kliknięcia. Atrybut type="module" daje nam zakres modułu (brak globalnych zmiennych) i defer za darmo.
Generowanie dni – createElement zamiast $.append
W starej wersji budowaliśmy dni tak:
// jQuery (2024)
for (let i = 1; i <= 31; i++) {
$("#calendar").append("<div>" + i + "</div>");
}Natywnie robi to to samo – i od razu dorzucamy optymalizację, którą w jQuery trzeba było świadomie dokładać (DocumentFragment). Tu budujemy gotowy ciąg HTML i wstawiamy go jedną operacją innerHTML, co daje dokładnie ten sam zysk (jeden reflow), ale bez dodatkowego obiektu:
const cells = [];
for (let i = 1; i <= 31; i++) {
cells.push(`<div>${i}</div>`);
}
calendar.innerHTML = cells.join('');
Siatka CSS Grid
CSS się praktycznie nie zmienia – w drugim wpisie serii przeszliśmy już z hacków float/clear na CSS Grid i to była dobra decyzja, która broni się do dziś. Siedem kolumn, a układ tygodni wynika z samej siatki:
month-calendar {
display: block;
max-width: 400px;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
}
.cal-grid div {
height: 50px;
line-height: 50px;
text-align: center;
background: lightgrey;
}
.cal-grid .day-name { color: white; background: darkgrey; }
.cal-grid .weekend { color: white; background: steelblue; }
.cal-grid .none { background: transparent; }
.cal-grid .today { color: white; background: crimson; }Jedyna nowość to wystylowanie samego custom-elementu (month-calendar { display: block }) – nieznane przeglądarce tagi są domyślnie inline.
Nazwy miesięcy i dni – Intl zamiast tablic (i całego API)
To jest miejsce, w którym nowa wersja wygrywa najmocniej. W starej serii nazwy trzymaliśmy w tablicach:
// jQuery (2024)
var monthName = ["Styczeń", "Luty", "Marzec", /* … */];
var dayName = ["Pon", "Wto", "Śro", "Czw", "Pią", "Sob", "Nie"];…a w trzecim wpisie zbudowaliśmy wokół tego całe zaplecze: tabelę translations w MySQL, skrypt API w PHP i ładowanie przez AJAX – tylko po to, żeby kalendarz znał słowo „Czerwiec” po polsku i „June” po angielsku. Dziś przeglądarka wie to sama, przez Intl.DateTimeFormat:
const title = new Intl.DateTimeFormat('pl-PL', {
month: 'long', year: 'numeric'
}).format(new Date(2026, 5, 1)); // "czerwiec 2026"
// Nazwy dni tygodnia – 1 stycznia 2024 wypadał w poniedziałek:
const fmt = new Intl.DateTimeFormat('pl-PL', { weekday: 'short' });
const weekdays = Array.from({ length: 7 },
(_, i) => fmt.format(new Date(2024, 0, 1 + i)));
// ["pon.", "wt.", "śr.", "czw.", "pt.", "sob.", "niedz."]Zmiana języka to teraz podmiana jednego ciągu ('pl-PL' → 'en-GB') – bez bazy, bez backendu, bez żądania sieciowego. Intl zna nie tylko angielski, ale i kilkaset innych lokalizacji wraz z ich konwencjami (np. polskie nazwy miesięcy są poprawnie małą literą).

Pełny komponent – nawigacja, weekend i „dziś”
Składamy wszystko w jeden Web Component. Logika obliczania pierwszego dnia miesiąca i liczby dni jest identyczna jak w starej serii (ten sam new Date(...).getDay() i new Date(year, month, 0).getDate()) – zmienia się tylko sposób budowania DOM i obsługi zdarzeń:
class MonthCalendar extends HTMLElement {
connectedCallback() {
const now = new Date();
this.year = now.getFullYear();
this.month = now.getMonth(); // 0–11
this.locale = this.getAttribute('locale') || 'pl-PL';
this.render();
// Delegacja zdarzeń – jeden listener na cały komponent,
// odpowiednik $(document).on('click', '.nav', …)
this.addEventListener('click', (e) => {
const nav = e.target.closest('[data-nav]');
if (nav) this.shift(Number(nav.dataset.nav));
});
}
shift(delta) {
const d = new Date(this.year, this.month + delta, 1);
this.year = d.getFullYear();
this.month = d.getMonth();
this.render(); // odpowiednik .empty().append(...)
}
render() {
const first = new Date(this.year, this.month, 1);
const startDay = (first.getDay() + 6) % 7; // 0 = poniedziałek … 6 = niedziela
const daysInMonth = new Date(this.year, this.month + 1, 0).getDate();
const title = new Intl.DateTimeFormat(this.locale, { month: 'long', year: 'numeric' }).format(first);
const dayFmt = new Intl.DateTimeFormat(this.locale, { weekday: 'short' });
const weekdays = Array.from({ length: 7 }, (_, i) => dayFmt.format(new Date(2024, 0, 1 + i)));
const today = new Date();
const thisMonth = today.getFullYear() === this.year && today.getMonth() === this.month;
const cells = weekdays.map((name, i) =>
`<div class="day-name${i >= 5 ? ' weekend' : ''}">${name}</div>`);
for (let i = 0; i < startDay; i++) cells.push('<div class="none"></div>');
for (let d = 1; d <= daysInMonth; d++) {
const col = (startDay + d - 1) % 7; // 0 = pon … 6 = niedz
let cls = '';
if (thisMonth && d === today.getDate()) cls = ' class="today"';
else if (col >= 5) cls = ' class="weekend"';
cells.push(`<div${cls}>${d}</div>`);
}
this.innerHTML = `
<div class="cal-head">
<button type="button" data-nav="-1" aria-label="Poprzedni miesiąc">‹</button>
<strong>${title}</strong>
<button type="button" data-nav="1" aria-label="Następny miesiąc">›</button>
</div>
<div class="cal-grid">${cells.join('')}</div>`;
}
}
customElements.define('month-calendar', MonthCalendar);Kilka rzeczy, które „za darmo” załatwiła zmiana podejścia:
- Delegacja zdarzeń przez
closest('[data-nav]')– jedenaddEventListenerzamiast$(document).on('click', '.nav', …). Nie tracimy podpięcia po przerenderowaniu, bo listener siedzi na komponencie, a nie na przyciskach. <button>zamiast<div>ze strzałką – nawigacja jest dostępna z klawiatury i dla czytników ekranu (aria-label), czego klikalnediv-y nigdy nie dały.- „Dziś” tylko w bieżącym miesiącu – ten sam warunek
month === (new Date()).getMonth(), którym w starej serii łataliśmy „urodziny co miesiąc”, jest tu od początku.

Gdy Intl to za mało – fetch zamiast $.ajax
Intl świetnie poda nazwy miesięcy i dni, ale w prawdziwej aplikacji często mamy własne napisy (np. etykiety wydarzeń, święta), które trzymamy po stronie serwera. To jedyny moment, w którym wracamy do API z trzeciego wpisu – tyle że bez jQuery. Stary kod:
// jQuery (2024)
$.ajax({
url: 'http://localhost/lang.php',
data: { lang: lang },
dataType: 'json',
success: function(data) { translations = data; generateCalendar(month, year); },
error: function(xhr, status) { alert('Błąd: ' + status); }
});…to dziś natywny fetch z async/await i czytelną obsługą błędów:
async function loadTranslations(lang) {
try {
const res = await fetch(`/lang.php?lang=${lang}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Nie udało się pobrać tłumaczeń:', err);
return {};
}
}Sam backend (PHP + MySQL z trzeciego wpisu) zostaje bez zmian – kontrakt to nadal JSON pod /lang.php?lang=…. Zmienił się tylko klient.
Podsumowanie
Ten sam kalendarz – miesięczna siatka, nawigacja, weekendy, podświetlony dzień, wielojęzyczność – powstał bez jednej linijki jQuery:
- DOM:
innerHTML/createElement+classListzamiast$().append().addClass(). - Zdarzenia:
addEventListener+ delegacja przezclosest()zamiast$(document).on(). - Lokalizacja:
Intl.DateTimeFormatzamiast tablic nazw – co skasowało całą warstwę API/bazy dla nazw miesięcy i dni. - Sieć:
fetch+async/awaitzamiast$.ajax, gdy potrzebujemy własnych napisów z serwera. - Opakowanie: Web Component (
<month-calendar>) zamiast luźnego skryptu na#calendar– enkapsulacja i reużywalność bez frameworka.
Efekt: zero zależności, lepsza dostępność (prawdziwe <button>-y) i kod, który nie potrzebuje warstwy pośredniej. jQuery zrobiło swoje w swojej epoce – ale dziś platforma webowa dogoniła i wyprzedziła to, po co kiedyś po nie sięgaliśmy.
Jeśli chcesz prześledzić, skąd przyszliśmy, zajrzyj do archiwalnej serii w jQuery: budowa, refaktoryzacja i CSS Grid oraz tłumaczenia z API.