Кнопка или ссылка: главная ошибка
Одна из самых частых ошибок в коде: элемент выглядит правильно, но ведёт себя не как обещает. Учимся отличать «ведёт» от «делает».
Ссылка (
<a href>) ведёт — переносит пользователя в другое место: на другую страницу, на другой сайт, к якорю. Кнопка (<button>) действует — что-то происходит здесь и сейчас: открывается модальное окно, отправляется форма, разворачивается аккордеон.
Зачем это различать
Может показаться, что это спор о вкусах: и то и другое кликается, и то и другое можно покрасить одинаково. Но кнопка и ссылка — разные элементы с разным контрактом поведения, и пользователи на этот контракт полагаются, даже не задумываясь.
Незрячий человек слышит «ссылка Удалить» и понимает: сейчас меня куда-то перебросит. Слышит «кнопка Удалить» — понимает: что-то произойдёт прямо здесь. Зрячий пользователь на ссылку может нажать средней кнопкой мыши, чтобы открыть в новой вкладке, а на кнопку — нет. Пользователь клавиатуры жмёт на кнопке Space, а на ссылке Space прокручивает страницу. Подменив один элемент другим, вы нарушаете обещание, которое дали интерфейсом.
Простой тест: если у действия есть URL, куда можно уйти, — это ссылка. Если URL нет — это кнопка. Всё.
Ошибка №1: ссылка, притворяющаяся кнопкой
Классика жанра — <a href="#"> с обработчиком клика:
Плохо:
<a href="#" class="btn" onclick="openModal(); return false;">Открыть настройки</a>
<a href="javascript:void(0)" class="btn">Добавить в корзину</a>
Что здесь ломается:
- Space не работает. На ссылке
Space— это «прокрутить страницу вниз». Пользователь клавиатуры жмёт пробел на том, что выглядит кнопкой, и вместо действия страница уезжает вниз. - Контекстное меню врёт. Правый клик предложит «Открыть в новой вкладке», «Копировать адрес ссылки». Откроется
#— то есть та же страница, но без модалки. Или пустая страницаjavascript:void(0). - Ctrl+click и средняя кнопка откроют новую вкладку в никуда — обработчик клика там не выполнится.
- Скринридер объявит «ссылка», и пользователь будет ждать перехода, которого не случится.
- История и якоря.
href="#"добавляет запись в историю и прыгает к началу страницы — потерянная позиция прокрутки. - Списки ссылок. Скринридеры умеют показывать список всех ссылок страницы. Ваши псевдокнопки засорят его бессмысленными пунктами.
Хорошо:
<button type="button" class="btn">Открыть настройки</button>
<button type="button" class="btn">Добавить в корзину</button>
Класс btn остался — выглядит так же. Но теперь работают Enter и Space, скринридер говорит «кнопка», контекстное меню не обещает лишнего.
Ошибка №2: кнопка, притворяющаяся ссылкой
Обратная беда, особенно частая в SPA-приложениях, где навигация делается программно:
Плохо:
<button type="button" class="link">Перейти в профиль</button>
// плохо: навигация «руками», в обход браузера
button.addEventListener('click', () => {
router.push('/profile');
});
Здесь теряется всё, что делает ссылку ссылкой:
- нельзя открыть в новой вкладке (
Ctrl+click, средняя кнопка мыши, «Открыть в новом окне»); - нельзя скопировать адрес или перетащить ссылку в закладки;
- браузер не покажет URL в строке состояния при наведении — человек не знает, куда попадёт;
- поисковый робот не пройдёт по этому «переходу» — страница выпадает из индекса;
- скринридер объявит «кнопка», хотя пользователь уедет со страницы.
Хорошо — настоящая ссылка, а роутер перехватывает клик сам:
<a href="/profile" class="link">Перейти в профиль</a>
Все нормальные роутеры (React Router, Vue Router, Nuxt) рендерят именно <a href> и перехватывают обычный клик, оставляя браузеру Ctrl+click и правый клик. Если ваш компонент «ссылки» рендерит <div> или <button> — это баг, а не оптимизация.
Что именно ломается: сводка
| Поведение | <a href> | <button> |
Срабатывает по Enter | да | да |
Срабатывает по Space | нет (прокрутка страницы) | да |
Открыть в новой вкладке (Ctrl/средняя кнопка) | да | нет |
| Контекстное меню «Копировать адрес» | да | нет |
| Виден в списке ссылок скринридера | да | нет (в списке кнопок) |
| Попадает в историю браузера | да | нет |
| Индексируется поисковиком | да | нет |
| Может отправить форму | нет | да (type="submit") |
Состояние disabled | нет | да |
Разница в строке про Space — самая коварная: она не видна ни в одном автоматическом аудите, но живой человек спотыкается о неё сразу.
Пограничные случаи
«Ссылка, стилизованная под кнопку» — это нормально
Крупная синяя «кнопка» Начать обучение, которая ведёт на /courses, — это ссылка, и должна быть ссылкой. Внешний вид и семантика живут отдельно: <a href="/courses" class="btn btn--primary">Начать обучение</a>. Ровно так же допустима кнопка, стилизованная под ссылку (подчёркнутый синий текст «Показать ещё»), если она разворачивает список, а не ведёт на URL.
Выход из аккаунта и удаление — не ссылки
<!-- опасно: любой префетчер, антивирус или ускоритель браузера может «сходить» по ссылке -->
<a href="/logout">Выйти</a>
<!-- правильно: действие, меняющее состояние, — это форма с кнопкой -->
<form method="post" action="/logout">
<button type="submit">Выйти</button>
</form>
Ссылка — это GET-запрос, который по спецификации не должен ничего менять. Действия, меняющие данные (выйти, удалить, отправить), делаются кнопкой.
Аккордеон, вкладки, «показать пароль» — кнопки
<button type="button" aria-expanded="false" aria-controls="faq-1">
Как отменить подписку?
</button>
<div id="faq-1" hidden>
<p>В настройках профиля, раздел «Оплата».</p>
</div>
Атрибут aria-expanded при клике переключаем на "true" — скринридер объявит «развёрнуто»/«свёрнуто». Это работает только на элементе с ролью кнопки.
Как это работает
Разное поведение Enter и Space — не каприз браузеров, а наследие настольных интерфейсов, закреплённое в спецификации. Активация ссылки — это «переход по умолчанию», привязанный к Enter; Space исторически закреплён за прокруткой документа и за нажатием кнопок и чекбоксов. Браузер знает это из роли элемента, а роль он берёт из тега. Отсюда и следствие: поставив на <a> атрибут role="button", вы измените только объявление скринридера, но не поведение браузера — Space по-прежнему не сработает, а обработчик клавиш придётся дописывать руками.
Ещё одна деталь, о которую спотыкаются постоянно: у <button> внутри <form> тип по умолчанию — submit. Кнопка «Показать пароль» без type="button" отправит форму. Правило простое: любой кнопке внутри формы всегда явно указывайте type.
Частые ошибки
<a href="#">иhref="javascript:void(0)"как замена кнопке — ломаетSpace, контекстное меню и историю.<button>сrouter.push()вместо ссылки — убивает открытие в новой вкладке и индексацию.role="button"на ссылке «чтобы скринридер сказал правильно» — вы поменяли только слова, а не поведение.- Кнопка в форме без
type="button"— неожиданная отправка формы. - Ссылка-иконка без текста: скринридер прочитает «ссылка» и замолчит. Нужен
aria-label. - Выход из аккаунта или удаление через
<a href>— GET не должен менять состояние. - Текст ссылки «Подробнее» ×20 на странице: в списке ссылок скринридера это двадцать одинаковых бесполезных строк. Пишите «Подробнее о тарифе Pro».
Итоги
- Ссылка ведёт, кнопка действует. Есть URL —
<a href>. Нет URL —<button>. - Внешний вид не решает: ссылка может выглядеть как кнопка и наоборот — это задача CSS.
- Главное техническое отличие:
Spaceнажимает кнопку, но прокручивает страницу на ссылке. - Псевдокнопка-ссылка ломает контекстное меню,
Ctrl+clickи историю; псевдоссылка-кнопка ломает новую вкладку и SEO. role="button"меняет объявление, но не поведение. Берите нативный тег.- Кнопкам внутри формы всегда ставьте явный
type.