Кнопка или ссылка: главная ошибка

Одна из самых частых ошибок в коде: элемент выглядит правильно, но ведёт себя не как обещает. Учимся отличать «ведёт» от «делает».

Ссылка (<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.
Проверьте себя
1. Пользователь клавиатуры нажимает Space на элементе a href="#", который стилизован под кнопку. Что произойдёт?
AСработает обработчик клика — так же, как на настоящей кнопке
BСтраница прокрутится вниз, действие не выполнится
CБраузер откроет ссылку в новой вкладке
DНичего не произойдёт: ссылки не реагируют на клавиатуру
2. В SPA пункт меню «Перейти в профиль» сделан как button с router.push('/profile'). Что теряет пользователь?
AНичего: браузер сам понимает, что это навигация
BТолько подсветку активного пункта меню
CВозможность открыть в новой вкладке, скопировать адрес и увидеть URL при наведении; плюс страница выпадает из индекса поисковика
DТолько работу клавиши Enter