Dynamiczny pasek postępu z interpolacją HSL i automatyczną kontrolą kontrastu WCAG 2.1
Tworzenie komponentów interfejsu spełniających standardy dostępności może wydawać się trudne — szczególnie gdy w grę wchodzą animacje, zmienne kolory oraz zmieniająca się treść. W tym artykule przeanalizujemy nowoczesny i dostępny pasek postępu, który:
- Zmienia kolor w czasie rzeczywistym (od zielonego → żółty → pomarańczowy → czerwony),
- Wykorzystuje interpolację HSL, aby uzyskać płynne przejścia barw,
- Zawsze zachowuje kontrast zgodny z WCAG 2.1 (co najmniej 4.5:1),
- Wyświetla aktualny procent na pasku, dopasowując kolor tekstu (biały/czarny),
- Pozwala sterować animacją — start, pauza, reset oraz regulowanie szybkości,
- Działa w pełni na HTML + JavaScript + Bootstrap 5.3, bez zewnętrznych bibliotek.
Przyjrzyjmy się, jak działa ta konstrukcja.
1. Dlaczego HSL?
W CSS istnieją różne sposoby definiowania kolorów: RGB, HEX, HSL. Dla animacji i płynnych przejść najwygodniejszy jest HSL, ponieważ:
- kolor (hue) jest oddzielony od jasności i nasycenia,
- interpolowanie wartości hue daje naturalne „przepłynięcie barwy”,
- manipulacja jasnością dla kontrastu jest znacznie łatwiejsza.
W naszym przykładzie zdefiniowane są cztery punkty procentowe:
| Procent | Barwa | HSL hue |
|---|---|---|
| 100% | zielony (success) | 140° |
| 66% | żółty (warning) | 50° |
| 33% | pomarańczowy | 30° |
| 0% | czerwony (danger) | 0° |
Kolor paska w chwili trwania animacji jest interpolowany między odpowiednimi wartościami hue — zapewnia to płynny gradient zmian w czasie.
2. Automatyczne zapewnianie kontrastu WCAG 2.1
Standard WCAG wymaga minimalnego kontrastu:
- 4.5:1 — dla tekstu zwykłego (co najmniej 14 px lub 18.5 px w pogrubieniu).
W naszym pasku tekstem jest bieżąca wartość procentowa — musi pozostać czytelna niezależnie od koloru tła.
Jak to osiągnięto?
- Najpierw kod wylicza kolor tła HSL.
- Następnie konwertuje HSL → RGB (wcześniej do linear RGB dla luminancji).
- Oblicza relatywną luminancję według wzoru WCAG.
- Sprawdza kontrast tekstu białego i czarnego.
- Jeśli żaden nie spełnia 4.5:1, skanuje pobliskie wartości jasności (L) w górę i w dół.
- Gdy znajdzie poprawną — stosuje ją natychmiast.
Dzięki temu:
- tło pozostaje kolorystycznie zgodne z zakładaną animacją,
- tekst zawsze jest czytelny,
- a jeśli wymagane — jasność koloru zostanie minimalnie skorygowana.
3. Sterowanie animacją
Kod implementuje trzy główne operacje:
Start — uruchamia odliczanie
Pauza — zatrzymuje zmianę wartości
Reset — przywraca 100% i odświeża kolor
Użytkownik może też ustawić szybkość odliczania (50–2000 ms), a skrypt dynamicznie reaguje na zmianę prędkości.
Dodatkowo:
- klawisz Spacja pauzuje i wznawia animację (z myślą o dostępności),
- wszystkie elementy mają atrybuty
aria-*, - pasek ma pełną kompatybilność z czytnikami ekranu.
4. Reaktywna aktualizacja paska
Gdy wartość procentowa spada:
- obliczany jest nowy hue,
- generowane jest HSL tła,
- dobierany jest kontrastujący kolor tekstu,
-
aktualizowane są:
- szerokość paska,
- tekst procentowy,
- aria-valuenow,
- kolor kontrastowy.
Wygląda to nowocześnie i zachowuje pełną dostępność.
5. Gotowy kod HTML
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Postęp z kolorami HSL i WCAG</title>
<!-- Bootstrap 5.3 CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { padding: 2rem; background: #f8f9fa; }
.progress { height: 3rem; border-radius: 0.75rem; overflow: hidden; }
.progress-bar { display: flex; align-items: center; justify-content: center; font-weight: 600; transition: width 300ms linear, background 300ms linear; }
.controls { gap: .5rem; }
</style>
</head>
<body>
<div class="container">
<h1 class="mb-4">Pasek postępu (100 → 0) z kolorami HSL i kontrolą kontrastu WCAG</h1>
<div class="mb-3">
<div class="progress" role="progressbar" aria-label="Pasek postępu" aria-valuemin="0" aria-valuemax="100" id="progressRoot">
<div id="progressBar" class="progress-bar" style="width:100%" aria-valuenow="100">100%</div>
</div>
</div>
<div class="d-flex align-items-center controls mb-3">
<button id="startBtn" class="btn btn-primary">Start</button>
<button id="pauseBtn" class="btn btn-secondary">Pauza</button>
<button id="resetBtn" class="btn btn-outline-secondary">Reset</button>
<label for="speed" class="ms-3 mb-0">Szybkość:</label>
<input id="speed" type="range" min="50" max="2000" value="150" class="form-range ms-2" style="width:220px">
<small class="ms-2">ms krok</small>
</div>
<p class="text-muted">Kolory są generowane w HSL (interpolacja pomiędzy zielonym → żółtym → pomarańczowym → czerwonym). Tekst na pasku automatycznie dobiera kolor (biały/czarny) tak, by spełnić kontrast WCAG 2.1 (ratio ≥ 4.5:1) — jeżeli to konieczne, jasność tła jest minimalnie korygowana.</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// --- Konfiguracja punktów kolorów (HUE) ---
// 100% -> success (zielony), 66% -> warning (żółty), 33% -> orange, 0% -> danger (czerwony)
const stops = [
{p: 100, h: 140}, // zielony
{p: 66, h: 50},
{p: 33, h: 30}, // pomarańcz
{p: 0, h: 0} // czerwony
];
// Naprawa literówki: zamieniamy funkcję pomocniczą na stałą wartość 50 (żółty)
stops[1].h = 50; // żółty
const progressBar = document.getElementById('progressBar');
const startBtn = document.getElementById('startBtn');
const pauseBtn = document.getElementById('pauseBtn');
const resetBtn = document.getElementById('resetBtn');
const speedInput = document.getElementById('speed');
let value = 100;
let timer = null;
// Interpolacja hue między punktami (procent w [0..100])
function hueForPercent(pct){
for(let i=0;i<stops.length-1;i++){
const a = stops[i], b = stops[i+1];
if(pct <= a.p && pct >= b.p){
const span = a.p - b.p;
const t = span === 0 ? 0 : (a.p - pct) / span; // 0..1
return a.h + (b.h - a.h) * t;
}
}
return stops[stops.length-1].h;
}
// HSL -> RGB (0..255)
function hslToRgb(h, s, l){
h = ((h % 360) + 360) % 360; s /= 100; l /= 100;
if(s === 0){
const v = Math.round(l * 255);
return [v,v,v];
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hk = h / 360;
const t = [hk + 1/3, hk, hk - 1/3];
const rgb = t.map(tc => {
if(tc < 0) tc += 1;
if(tc > 1) tc -= 1;
if(tc < 1/6) return p + (q - p) * 6 * tc;
if(tc < 1/2) return q;
if(tc < 2/3) return p + (q - p) * (2/3 - tc) * 6;
return p;
});
return rgb.map(v => Math.round(v * 255));
}
// Relatywna luminancja zgodnie z WCAG
function relLuminance(r,g,b){
const toLin = c => {
c /= 255;
return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
};
return 0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b);
}
// Kontrast ratio między dwoma kolorami (rgb 0..255)
function contrastRatio(rgb1, rgb2){
const L1 = relLuminance(rgb1[0], rgb1[1], rgb1[2]);
const L2 = relLuminance(rgb2[0], rgb2[1], rgb2[2]);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
// Zapewnij kontrast: dla danego (h,s,lStart) znajdź l w okolicy, by biały lub czarny miały ratio >= 4.5
function ensureWcag(h, s, lStart){
const targetRatio = 4.5;
// Spróbuj najpierw bez zmiany l
for(const textRgb of [[255,255,255],[0,0,0]]){
const rgb = hslToRgb(h,s,lStart);
if(contrastRatio(rgb, textRgb) >= targetRatio) return {l: lStart, textRgb};
}
// Jeśli nie, skanuj w górę i w dół od lStart w kroku 1
let best = null;
for(let delta=1; delta<=40; delta++){
for(const dir of [1,-1]){
const l = Math.min(90, Math.max(10, lStart + dir*delta));
const rgb = hslToRgb(h,s,l);
for(const textRgb of [[255,255,255],[0,0,0]]){
const ratio = contrastRatio(rgb, textRgb);
if(ratio >= targetRatio) return {l, textRgb};
if(!best || ratio > best.ratio) best = {l, textRgb, ratio};
}
}
}
// Jeśli nic nie osiągnęło 4.5, zwróć najlepsze co mieliśmy
return {l: best.l, textRgb: best.textRgb};
}
// Aktualizuj pasek na podstawie wartości (0..100)
function updateProgress(v){
const pct = Math.max(0, Math.min(100, Math.round(v)));
const hue = hueForPercent(pct);
const saturation = 75; // %
const baseLightness = 47; // preferowana jasność
const wcag = ensureWcag(hue, saturation, baseLightness);
const finalL = wcag.l;
const textRgb = wcag.textRgb;
// Ustaw styl (używamy modern hsl bez przecinków)
progressBar.style.background = `hsl(${Math.round(hue)} ${saturation}% ${Math.round(finalL)}%)`;
progressBar.style.width = pct + '%';
progressBar.setAttribute('aria-valuenow', String(pct));
// Tekst: procent + ewent. aria-label
progressBar.textContent = pct + '%';
// Ustaw kolor tekstu na biały lub czarny wg znalezionego najlepszego
const textColor = (textRgb[0] === 255 && textRgb[1] === 255 && textRgb[2] === 255) ? '#fff' : '#000';
progressBar.style.color = textColor;
// Dla dodatkowego kontrastu używamy lekkiego cienia tekstu gdy tekst jest biały
if(textColor === '#fff') progressBar.style.textShadow = '0 1px 0 rgba(0,0,0,0.25)';
else progressBar.style.textShadow = 'none';
}
// Funkcja animująca od 100 do 0
function startCountdown(){
if(timer) return;
timer = setInterval(() => {
value -= 1;
updateProgress(value);
if(value <= 0){
clearInterval(timer); timer = null; value = 0;
}
}, parseInt(speedInput.value, 10));
}
function pauseCountdown(){
if(timer){ clearInterval(timer); timer = null; }
}
function resetCountdown(){
pauseCountdown(); value = 100; updateProgress(value);
}
// Podłącz przyciski
startBtn.addEventListener('click', startCountdown);
pauseBtn.addEventListener('click', pauseCountdown);
resetBtn.addEventListener('click', resetCountdown);
// Allow changing speed while running
speedInput.addEventListener('input', () => {
if(timer){ pauseCountdown(); startCountdown(); }
});
// Inicjalizacja
updateProgress(value);
// Accessibility: keyboard start/pause via space
document.addEventListener('keydown', (e) => {
if(e.code === 'Space'){
e.preventDefault();
if(timer) pauseCountdown(); else startCountdown();
}
});
</script>
</body>
</html>
6. Działający przykład
Kolory są generowane w HSL (interpolacja pomiędzy zielonym → żółtym → pomarańczowym → czerwonym). Tekst na pasku automatycznie dobiera kolor (biały/czarny) tak, by spełnić kontrast WCAG 2.1 (ratio ≥ 4.5:1) — jeżeli to konieczne, jasność tła jest minimalnie korygowana.
7. Podsumowanie
Przedstawiony pasek postępu to świetny przykład komponentu łączącego:
- estetykę — płynna i naturalna animacja barw w HSL,
- użyteczność — czytelny tekst z dynamiczną aktualizacją,
- dostępność — automatyczne spełnianie WCAG 2.1,
- interaktywność — sterowanie czasem i animacją,
- czysty, nowoczesny kod — bazujący tylko na natywnym JS + Bootstrap 5.3.
Możesz go wykorzystać jako:
- wskaźnik ładowania,
- licznik czasu,
- licznik do eventów,
- pasek zdrowia (np. w grach),
- narzędzie edukacyjne do nauki HSL i kontrastu WCAG.