Przejdź do głównej treści
Grafika przedstawia ukryty obrazek

Dynamiczny pasek postępu z interpolacją HSL i automatyczną kontrolą kontrastu WCAG 2.1

Zdjecie zwiazane z Dynamiczny pasek postpu z interpolacj HSL i automatyczn kontrol kontrastu WCAG 21

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)

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?

  1. Najpierw kod wylicza kolor tła HSL.
  2. Następnie konwertuje HSL → RGB (wcześniej do linear RGB dla luminancji).
  3. Oblicza relatywną luminancję według wzoru WCAG.
  4. Sprawdza kontrast tekstu białego i czarnego.
  5. Jeśli żaden nie spełnia 4.5:1, skanuje pobliskie wartości jasności (L) w górę i w dół.
  6. 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:

  1. obliczany jest nowy hue,
  2. generowane jest HSL tła,
  3. dobierany jest kontrastujący kolor tekstu,
  4. 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

100%
ms krok

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.
21 listopada 2025 3

Kategorie

programowanie

Dziękujemy!
()

Powiązane wpisy

Zdjecie zwiazane z Long Polling vs Short Polling Porwnanie
21 stycznia 2025 5 min 26

Long Polling vs. Short Polling: Porównanie

Czytaj więcej
Grafika przedstawia Wyraenia regularne i ich obsuga w PHP oraz JavaScript
1 lutego 2025 5 min 19

Wyrażenia regularne i ich obsługa w PHP oraz JavaScript

Czytaj więcej
Wizualizacja tematu Jak zrealizowa dwukierunkowe wizanie danych MVVM w Vanilla JS
2 lutego 2025 3 min 18

Jak zrealizować dwukierunkowe wiązanie danych MVVM w Vanilla JS?

Czytaj więcej
Wymiana doświadczeń

Masz podobne doświadczenia?

Chętnie poznam Twoją perspektywę i porozmawiam o tym temacie szerzej.

Napisz do mnie

Każda perspektywa może wnieść coś wartościowego do dyskusji.

Twoja prywatność i pliki cookies

  1. Ta strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania – m.in. do poprawnego wyświetlania treści, zapamiętania podstawowych ustawień przeglądarki oraz zapewnienia stabilności serwisu.
  2. Nie stosuję plików cookies w celach marketingowych, reklamowych ani analitycznych.
  3. Strona ma charakter wyłącznie informacyjny i nie zawiera formularzy kontaktowych, rejestracyjnych ani zakupowych, przez które dane mogłyby być przesyłane na serwer.
  4. Nie zbieram danych osobowych podczas zwykłego korzystania z witryny.
  5. Serwis nie korzysta z certyfikatu SSL, jednak ze względu na informacyjny charakter strony nie jest wymagane przesyłanie poufnych danych. Zalecam jednak, aby nigdy nie wpisywać haseł ani danych osobowych na stronach bez szyfrowanego połączenia.
  6. Korzystając z tej strony, wyrażasz zgodę na używanie wyłącznie niezbędnych plików cookies.

Więcej informacji znajdziesz w mojej polityce prywatności.