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

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

Wizualizacja tematu Dynamiczny pasek postpu z interpolacj HSL i automatyczn kontrol kontrastu WCAG 2

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 2

Kategorie

programowanie

Dziękujemy!
()

Powiązane wpisy


Informacja o cookies

Moja strona internetowa wykorzystuje wyłącznie niezbędne pliki cookies, które są wymagane do jej prawidłowego działania. Nie używam ciasteczek w celach marketingowych ani analitycznych. Korzystając z mojej strony, wyrażasz zgodę na stosowanie tych plików. Możesz dowiedzieć się więcej w mojej polityce prywatności.