Stany przycisków CSS to style, które przycisk pokazuje po najechaniu (:hover), przy fokusie z klawiatury (:focus-visible), podczas wciśnięcia (:active), po wyłączeniu (:disabled lub aria-disabled) i w trakcie ładowania. Oto jeden przycisk w każdym stanie, z czystym CSS i Tailwind do skopiowania, a także dostępne przyciski outline, ghost, zamykania i z ikoną.
Przycisk ma sześć stanów, które warto zaprojektować: domyślny, hover, focus-visible, active, disabled i ładowanie. Większość fragmentów kodu w sieci pokazuje jeden lub dwa z nich, na różnych przyciskach i z różnym kodem. Ten poradnik przez cały czas używa jednego przycisku, pokazuje wszystkie stany obok siebie i dla każdego podaje czysty CSS oraz Tailwind do skopiowania. Następnie omawia najczęściej wyszukiwane warianty (outline, ghost, zamykania, z ikoną) oraz różnicę między disabled a aria-disabled, która jest czymś więcej niż kwestią stylu.
Każdy kafelek poniżej pokazuje jeden stan, dzięki czemu porównasz je jednym rzutem oka. Działający przycisk pod siatką pozwala wymusić stan albo wypróbować go własną myszą i klawiaturą.
| Stan | Selektor CSS | Wariant Tailwind | Co go wywołuje |
|---|---|---|---|
| Domyślny | .btn | (bez prefiksu) | Nic |
| Hover | .btn:hover | hover: | Kursor nad przyciskiem. Ekrany dotykowe nie mają prawdziwego hover. |
| Focus-visible | .btn:focus-visible | focus-visible: | Fokus, który przeglądarka uznaje za wart pokazania, zwykle fokus z klawiatury (Tab) |
| Focus | .btn:focus | focus: | Każdy fokus, w większości przeglądarek także po kliknięciu myszą |
| Active | .btn:active | active: | Gdy przycisk myszy, palec lub klawisz Space jest przytrzymany |
| Disabled | .btn:disabled | disabled: | Atrybut disabled |
| Disabled (ARIA) | .btn[aria-disabled="true"] | aria-disabled: | Atrybut aria-disabled="true" |
| Ładowanie | .btn[aria-busy="true"] | aria-busy: | Twój skrypt, w trakcie trwania pracy |
W czystym CSS kolejność ma znaczenie, gdy selektory mają równą specyficzność: najpierw :hover, potem :focus-visible, potem :active, aby wciśnięcie wygrywało z najechaniem. Tailwind sortuje swoje warianty za Ciebie.
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.5rem; /* 40px, above the 24px WCAG minimum */
padding: 0.625rem 1rem;
border: 1px solid transparent; /* keeps size equal to the outline variant */
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
font: 600 0.875rem/1.25rem system-ui, sans-serif;
cursor: pointer;
transition: background-color 150ms, transform 150ms;
}
/* hover only when the button can actually be used */
.btn:hover:not(:disabled, [aria-disabled="true"]) {
background: #1d4ed8;
}
/* keyboard focus; mouse clicks do not show the ring */
.btn:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.btn:active:not(:disabled, [aria-disabled="true"]) {
background: #1e40af;
transform: translateY(1px);
}
.btn:disabled,
.btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.btn[aria-busy="true"] {
cursor: progress;
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn:active { transform: none; }
}
Większość pracy wykonują trzy szczegóły. Zabezpieczenie :not(:disabled, [aria-disabled="true"]) sprawia, że wyłączony przycisk nie reaguje na hover ani wciśnięcie, przez co inaczej wyglądałby na klikalny. Przezroczyste obramowanie utrzymuje pełny przycisk w tym samym rozmiarze co wariant outline, więc zamiana wariantów nie przesuwa układu. A :focus-visible zamiast :focus oznacza, że użytkownicy myszy nie widzą pierścienia po kliknięciu, a użytkownicy klawiatury widzą go zawsze.
Używaj go tak: <button type="button" class="btn">Save changes</button>. Zawsze ustawiaj type: <button> wewnątrz formularza domyślnie ma type="submit" i po kliknięciu wyśle formularz.
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-transparent
bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition
hover:bg-blue-700 active:translate-y-px active:bg-blue-800
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-blue-600 disabled:active:translate-y-0
aria-disabled:cursor-not-allowed aria-disabled:opacity-50
motion-reduce:transition-none">
Save changes
</button>
Te klasy działają w Tailwind 3.4 i 4. Dwie uwagi dotyczące v4: hover: działa tylko na urządzeniach obsługujących hover (@media (hover: hover)), a style bazowe v4 nadają przyciskom cursor: default, więc dodaj cursor-pointer, jeśli chcesz kursor w kształcie dłoni. Pozostałe zmienione ustawienia domyślne, takie jak ring o grubości 1px, znajdziesz w ściądze Tailwind v4.
WCAG 2.4.7 Focus Visible (poziom AA) wymaga, aby fokus klawiatury był widoczny. Usunięcie obrysu przeglądarki przez outline: none bez dodania niczego w zamian nie spełnia tego kryterium. Wskazówki, które się sprawdzają:
:focus-visible dla pierścienia. Obsługują go wszystkie obecne główne przeglądarki, więc dawne obejście :focus:not(:focus-visible) nie jest już potrzebne.outline zamiast box-shadow. Obrysy pozostają widoczne w trybie wymuszonych kolorów Windows (wysoki kontrast), w którym cienie są usuwane.outline-offset o wartości 2px zostawia odstęp między przyciskiem a pierścieniem, więc niebieski pierścień jest nadal widoczny przy niebieskim przycisku.Przycisk w stanie ładowania musi robić cztery rzeczy: pokazywać, że trwa praca, blokować drugie wysłanie, zachować fokus klawiatury tam, gdzie był, i informować użytkowników czytników ekranu o zakończeniu pracy. Ustawienie disabled na czas żądania łamie trzeci punkt: przycisk traci fokus, a w niektórych przeglądarkach użytkownicy klawiatury wracają na początek strony. Zamiast tego użyj aria-disabled i zabezpieczenia w skrypcie.
<button type="submit" class="btn" aria-disabled="true" aria-busy="true">
<svg class="spinner" viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3"/>
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
</svg>
Saving
</button>
<!-- one live region on the page, outside the button -->
<p class="visually-hidden" role="status">Saving your changes</p>
.spinner { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2.4s; } /* slow it, keep a sign of progress */
}
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (button.getAttribute("aria-disabled") === "true") return; // ignore double clicks
button.setAttribute("aria-disabled", "true");
button.setAttribute("aria-busy", "true");
status.textContent = "Saving your changes";
try {
await save(new FormData(form));
status.textContent = "Saved";
} catch {
status.textContent = "Could not save. Try again.";
} finally {
button.removeAttribute("aria-disabled");
button.removeAttribute("aria-busy");
}
});
Zostaw etykietę w formie czasownika ("Zapisywanie", "Wysyłanie") zamiast zastępować ją samym spinnerem, utrzymuj stałą szerokość przycisku (min-width nie pozwala mu się kurczyć przy zmianie tekstu) i ogłaszaj wynik przez region statusu, a nie wyłącznie zmianą tekstu przycisku. aria-busy to wskazówka, że element jest aktualizowany. Czytniki ekranu różnie ją traktują, dlatego właściwy komunikat niesie wiadomość statusu.
Oba sprawiają, że technologie wspomagające ogłaszają przycisk jako niedostępny (często "wygaszony" lub "niedostępny"). Różnica dotyczy zachowania. Jak ujmuje to MDN, atrybut HTML disabled wyłącza całą funkcjonalność, a aria-disabled="true" tylko udostępnia informację o stanie wyłączenia, więc zachowanie musisz zablokować samodzielnie.
disabled | aria-disabled="true" | |
|---|---|---|
| Dostępny klawiszem Tab | Nie, wypada z kolejności tabulacji | Tak, nadal może otrzymać fokus |
| Aktywacja kliknięciem i klawiaturą | Blokowana przez przeglądarkę | Nadal następuje: Twój skrypt musi ją ignorować |
| Wysyła formularz | Nie. Jego nazwa i wartość nie są wysyłane, a jeśli jest domyślnym przyciskiem formularza, naciśnięcie Enter w polu nie wysyła formularza | Tak, chyba że wywołasz preventDefault() |
| Może pokazać podpowiedź lub wyjaśnienie przy fokusie | Nie, nie może otrzymać fokusu | Tak, z aria-describedby |
| Punkt zaczepienia dla stylów | :disabled / disabled: | [aria-disabled="true"] / aria-disabled: |
Działa na linkach (<a>) | Nie, disabled nie jest poprawny na linkach | Tak, w połączeniu z usunięciem href |
Praktyczna zasada: używaj disabled, gdy przycisku naprawdę nie da się użyć i nikt nie musi odkrywać dlaczego, na przykład kontrolki formularza w wyłączonej sekcji. Używaj aria-disabled, gdy użytkownik musi znaleźć przycisk i dowiedzieć się, czego brakuje ("Podaj swój e-mail, aby kontynuować"), oraz podczas ładowania. Często najlepsza odpowiedź to żadne z nich: zostaw przycisk włączony i pokaż jasny komunikat o błędzie, gdy zostanie naciśnięty przy brakujących danych.
button.addEventListener("click", (event) => {
if (button.getAttribute("aria-disabled") === "true") {
event.preventDefault(); // also stops a submit button from submitting
return;
}
doTheThing();
});
O kontraście: WCAG 1.4.3 zwalnia tekst w nieaktywnych (wyłączonych) komponentach z wymogu minimalnego kontrastu, dlatego przygaszony przycisk z przezroczystością 50% jest dozwolony. Mimo to warto zachować czytelność etykiety, bo ludzie muszą widzieć, czego jeszcze nie mogą zrobić.
Przycisk outline ma przezroczyste wypełnienie i widoczne obramowanie, a przycisk ghost nie ma ani jednego, ani drugiego, dopóki nie najedziesz na niego kursorem. Używaj ich do mniej ważnych akcji obok pełnego przycisku głównego: outline dla wyraźnego drugiego wyboru ("Podgląd"), ghost dla cichych akcji w paskach narzędzi i menu ("Anuluj", "Edytuj").
To prawdziwe przyciski: najedź na nie kursorem i naciśnij Tab, aby zobaczyć każdy stan. CSS dla wariantów, nałożony na powyższą bazę .btn:
/* Outline: transparent fill, visible border */
.btn-outline {
background: transparent;
color: #1d4ed8;
border-color: #2563eb;
}
.btn-outline:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-outline:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }
/* Ghost: no fill, no visible border until hover */
.btn-ghost {
background: transparent;
color: #1d4ed8;
border-color: transparent;
}
.btn-ghost:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-ghost:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }
/* Destructive */
.btn-danger { background: #dc2626; }
.btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: #b91c1c; }
.btn-danger:focus-visible { outline-color: #dc2626; }
<!-- Outline -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-blue-600 bg-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
Outline
</button>
<!-- Ghost -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
Ghost
</button>
<!-- Destructive -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent bg-red-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-red-700 active:bg-red-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:opacity-50">
Delete project
</button>
WCAG 1.4.11 Non-text Contrast wymaga kontrastu 3:1 względem sąsiednich kolorów dla części kontrolki potrzebnych do jej rozpoznania. Obramowanie blue-600 wariantu outline na białym tle to spełnia. Przycisk ghost w spoczynku nie ma granicy, więc to etykieta i położenie sprawiają, że wygląda na klikalny: umieszczaj przyciski ghost tam, gdzie ludzie spodziewają się akcji, na przykład w paskach narzędzi, oknach dialogowych i stopkach kart, a kolor ich tekstu utrzymuj na tyle ciemny, by spełniał kontrast zwykłego tekstu (4.5:1).
Ikona X nie ma tekstu, więc potrzebuje dostępnej nazwy. Dodaj aria-label="Close" do przycisku i aria-hidden="true" do SVG, aby czytniki ekranu ogłaszały "Zamknij, przycisk" zamiast niczego albo nazwy pliku. Jeśli strona ma kilka przycisków zamykania, nadaj im konkretne nazwy: "Zamknij informację o cookies", "Zamknij okno dialogowe".
Twoje zmiany zostały zapisane.
<button type="button" class="btn-close" aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
.btn-close {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem; /* 40px target; WCAG 2.2 AA minimum is 24 by 24 */
padding: 0;
border: 0;
border-radius: 9999px;
background: transparent;
color: #334155;
cursor: pointer;
}
.btn-close:hover { background: rgb(15 23 42 / 0.08); }
.btn-close:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
<button type="button" aria-label="Close"
class="inline-grid size-10 place-items-center rounded-full text-slate-700 hover:bg-slate-900/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg viewBox="0 0 24 24" class="size-5" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
Wymiaruj obszar kliknięcia, a nie tylko ikonę. WCAG 2.5.8 Target Size (Minimum), poziom AA w WCAG 2.2, wymaga celów o wielkości co najmniej 24 na 24 piksele CSS (z wyjątkami, na przykład gdy wokół mniejszego celu jest wystarczająco dużo miejsca). Powyższy przycisk 40px spełnia to z zapasem, a narysowany X ma nadal 20px. Unikaj używania litery "x" lub znaku "×" jako jedynej zawartości bez etykiety: czytniki ekranu odczytują go jako "x" lub "razy".
<!-- Icon + text: the text is the name, the icon is decoration -->
<button type="button" class="btn">
<svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24">...</svg>
Download PDF
</button>
<!-- Icon only, option 1: aria-label -->
<button type="button" class="btn btn-icon" aria-label="Download PDF">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
</button>
<!-- Icon only, option 2: visually hidden text (gets translated by browser translation tools) -->
<button type="button" class="btn btn-icon">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
<span class="visually-hidden">Download PDF</span>
</button>
.btn svg { flex: none; } /* keep icons from squashing */
.btn-icon {
width: 2.5rem;
padding: 0; /* square: same width and height */
}
<button type="button" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-4 shrink-0" aria-hidden="true" ...></svg>
Download PDF
</button>
<button type="button" aria-label="Download PDF" class="inline-grid size-10 place-items-center rounded-lg bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-5" aria-hidden="true" ...></svg>
</button>
aria-hidden="true"), a nazwą niech będzie tekst. Używaj gap zamiast marginesu na ikonie, aby odstępy działały także po usunięciu ikony lub przeniesieniu jej na prawo.aria-label lub wizualnie ukryty tekst. Nie polegaj na title: przeglądarki używają go jako nazwy zapasowej, ale jego podpowiedź pojawia się tylko po najechaniu myszą, a nie przy fokusie klawiatury czy dotyku.focusable="false" do osadzonych SVG, jeśli nadal wspierasz stare wersje Internet Explorer, które czyniły z SVG przystanki tabulacji. Nowoczesne przeglądarki tego nie potrzebują, ale to nieszkodliwe.<button> (dla akcji) lub <a href> (dla nawigacji), a nie klikalny <div>.type="button", chyba że ma wysyłać formularz.prefers-reduced-motion.Aby zaprojektować przycisk wizualnie i skopiować jego CSS, użyj generatora przycisków CSS. Gotowe zestawy znajdziesz wśród komponentów przycisków i animowanych przycisków. Dwa elementy, które dobrze uzupełniają ten poradnik: zestaw przycisków HTML i CSS ze stylami primary, secondary, outline, ghost, z ikoną, ładowaniem i akcją destrukcyjną oraz kolekcja stylów przycisków CSS z HTML i CSS do skopiowania. O tym, gdzie umieszczać przyciski na stronie i co powinny mówić, przeczytasz w przykładach wezwań do działania.
Użyj pseudoklasy :disabled, na przykład .btn:disabled { opacity: 0.5; cursor: not-allowed; }. Jeśli używasz też aria-disabled, dodaj [aria-disabled="true"] do tej samej reguły. Zabezpiecz style hover i active przez :not(:disabled), aby wyłączony przycisk nie zmieniał koloru po najechaniu lub wciśnięciu.
disabled usuwa przycisk z kolejności tabulacji, blokuje kliknięcia i nie pozwala mu wysłać formularza. aria-disabled="true" tylko informuje technologie wspomagające, że przycisk jest niedostępny: nadal można na niego przenieść fokus i go kliknąć, więc Twój skrypt musi ignorować aktywację. Używaj aria-disabled, gdy użytkownicy muszą przenieść fokus na przycisk, aby dowiedzieć się, dlaczego jest niedostępny, albo podczas ładowania żądania.
Do widocznego pierścienia używaj :focus-visible. Przeglądarka pokazuje go przy fokusie z klawiatury, ale zwykle nie po kliknięciu myszą, więc użytkownicy klawiatury zawsze widzą, gdzie są, a pierścień nie pojawia się przy każdym kliknięciu. Nigdy nie usuwaj obrysu bez dodania widocznego zamiennika, bo WCAG 2.4.7 wymaga widocznego wskaźnika fokusu.
Użyj prawdziwego elementu button z type="button" i aria-label="Close" (albo bardziej konkretną nazwą, na przykład "Close dialog") i dodaj aria-hidden="true" do ikony X. Obszar kliknięcia powinien mieć co najmniej 24 na 24 piksele CSS, czyli minimum WCAG 2.2 AA. W przycisk o wielkości od 40 do 44 pikseli łatwiej trafić.
Przycisk outline ma przezroczyste tło i widoczne obramowanie, więc w spoczynku wygląda jak przycisk. Przycisk ghost nie ma tła ani widocznego obramowania, dopóki nie najedziesz na niego kursorem, więc polega na tekście i położeniu. Używaj outline dla wyraźnej akcji drugorzędnej, a ghost dla cichych akcji w paskach narzędzi lub menu.
Dodaj spinner z aria-hidden="true", zmień etykietę na czasownik, na przykład Zapisywanie, ustaw aria-disabled="true", aby drugie kliknięcie było ignorowane, a fokus pozostał na przycisku, i ogłoś wynik przez region role="status". Usuń atrybuty, gdy żądanie się zakończy lub nie powiedzie.
Zapisywanie zmian
Zapisano
Wymuś stan
na żywo (wypróbuj)
Zapisywanie
Zapisz zmiany
Najedź kursorem, przejdź do niego klawiszem Tab, wciśnij go lub kliknij, aby zobaczyć ładowanie.
Pokazano stan {state}.
Zdarzenie click zostało wywołane, skrypt je zignorował. Podaj swój e-mail, aby kontynuować.
Kliknij oba, a potem przejdź po nich klawiszem Tab: tylko do jednego da się dotrzeć.
Kontynuuj
Odebrane zdarzenia click: {n}
Zacznij za darmo · Bez karty kredytowej