CSS hover эффект при наведении – рабочие примеры CSS и HTML

CSS hover эффект при наведении меняет состояние элемента, пока указатель находится над ним. Скопируйте первый пример, затем выберите прием для кнопки, изображения, иконки или карточки. В каждом примере эффекта есть минимальная разметка и CSS, поэтому вы сможете заменить цвет, направление и длительность без JavaScript.

Как работает CSS hover эффект при наведении

Псевдокласс :hover выбирает элемент под указателем. Свойство transition плавно меняет значение между обычным состоянием и состоянием наведения.

<button class='demo' type='button'>Навести указатель</button>
<style>
.demo { padding: 12px 20px; color: white; background: #1746a2; border: 0; transition: background-color .2s ease-out; }
.demo:hover, .demo:focus-visible { background: #102e6c; }
.demo:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
</style>

Цвет меняется за .2s с функцией ease-out. Правило перехода стоит в базовом состоянии, поэтому возврат после ухода указателя тоже плавный.

Состояние описывают в CSS, когда отклик не меняет данные. Обработчик клика нужен для действия, а не для обычной подсветки. Для последовательности кадров и повторения применяйте @keyframes. Подробное описание псевдокласса есть в документации MDN о :hover.

Эффекты для кнопок

Кнопочный отклик можно строить на заливке, линии, смещении с тенью или заранее зарезервированной рамке. Выбирайте прием по характеру действия и следите, чтобы фокус и размеры кнопки оставались предсказуемыми.

Заливка слева направо

<button class='fill-btn' type='button'>Добавить</button>
<style>
.fill-btn { position: relative; isolation: isolate; overflow: hidden; padding: 12px 24px; border: 2px solid #1746a2; border-radius: 8px; background: white; color: #1746a2; font: inherit; cursor: pointer; transition: color .2s ease-out; }
.fill-btn::before { content: ''; position: absolute; inset: 0; z-index: -1; background: #1746a2; transform: scaleX(0); transform-origin: left; transition: transform .2s ease-out; }
.fill-btn:hover, .fill-btn:focus-visible { color: white; }
.fill-btn:hover::before, .fill-btn:focus-visible::before { transform: scaleX(1); }
.fill-btn:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .fill-btn, .fill-btn::before { transition: none; } }
</style>

Псевдоэлемент раскрывается через scaleX(1), поэтому кнопка не меняет размеры. Замените два цвета, а для движения справа укажите transform-origin: right.

Подчеркивание из ::after

<button class='line-btn' type='button'>Подробнее</button>
<style>
.line-btn { position: relative; padding: 12px 4px; border: 0; background: transparent; color: #1746a2; font: inherit; cursor: pointer; }
.line-btn::after { content: ''; position: absolute; left: 4px; right: 4px; bottom: 4px; height: 2px; background: currentColor; transform: scaleX(0); transform-origin: left; transition: transform .2s ease-out; }
.line-btn:hover::after, .line-btn:focus-visible::after, .line-btn:active::after { transform: scaleX(1); }
.line-btn:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .line-btn::after { transition: none; } }
</style>

currentColor берет цвет текста кнопки. Измените transform-origin на center, чтобы линия раскрывалась от середины.

Подъем с тенью

<button class='lift-btn' type='button'>Выбрать</button>
<style>
.lift-btn { position: relative; isolation: isolate; padding: 12px 24px; border: 0; border-radius: 8px; background: #1746a2; color: white; font: inherit; cursor: pointer; transition: transform .2s ease-out; }
.lift-btn::after { content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; box-shadow: 0 8px 18px rgb(0 0 0 / .28); opacity: 0; pointer-events: none; transition: opacity .2s ease-out; }
.lift-btn:hover { transform: translateY(-3px); }
.lift-btn:hover::after, .lift-btn:focus-visible::after { opacity: 1; }
.lift-btn:active { transform: translateY(1px); }
.lift-btn:focus-visible { outline: 3px solid #111; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .lift-btn, .lift-btn::after { transition: none; } .lift-btn:hover, .lift-btn:active { transform: none; } }
</style>

Кнопка смещается на 3 пикселя, а готовая тень появляется через opacity. Контейнер вокруг кнопки не должен обрезать тень свойством overflow: hidden.

Обводка без скачка размеров

<button class='border-btn' type='button'>Настройки</button>
<style>
.border-btn { box-sizing: border-box; padding: 12px 24px; border: 2px solid transparent; border-radius: 8px; background: #e9efff; color: #12336e; font: inherit; cursor: pointer; transition: border-color .2s ease-out; }
.border-btn:hover, .border-btn:focus-visible, .border-btn:active { border-color: #1746a2; }
.border-btn:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .border-btn { transition: none; } }
</style>

Прозрачная рамка заранее занимает 2 пикселя. При наведении меняется только ее цвет, поэтому соседние элементы не сдвигаются.

Эффекты для изображений

Для изображений подходят зум, затемнение, выезжающая подпись и переворот карточки. При выборе сохраняйте видимыми название и ссылку, чтобы смысл и действие не зависели от наведения.

Зум внутри контейнера

<a class='zoom-card' href='#zoom-details'>
  <img src='photo.jpg' alt='Горный маршрут' />
  <span>Открыть маршрут</span>
</a>
<p id='zoom-details'>Описание горного маршрута.</p>
<style>
.zoom-card { display: block; width: min(100%, 320px); overflow: hidden; border-radius: 12px; color: #12336e; background: #edf3ff; }
.zoom-card img { display: block; width: 100%; height: auto; transition: transform .25s ease-out; }
.zoom-card span { display: block; padding: 12px; background: #edf3ff; }
.zoom-card:hover img, .zoom-card:focus-visible img { transform: scale(1.06); }
.zoom-card:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .zoom-card img { transition: none; transform: none !important; } }
</style>

overflow: hidden обрезает увеличенную картинку внутри карточки. Значение 1.06 дает небольшой зум без изменения ширины блока.

Затемнение оверлеем

<a class='shade-card' href='#shade-details'>
  <img src='photo.jpg' alt='Горный маршрут' />
  <span>Поход в горы</span>
</a>
<p id='shade-details'>Программа похода и список снаряжения.</p>
<style>
.shade-card { position: relative; display: block; width: min(100%, 320px); overflow: hidden; border-radius: 12px; color: white; }
.shade-card img { display: block; width: 100%; height: auto; }
.shade-card::after { content: ''; position: absolute; inset: 0; background: #000; opacity: .12; pointer-events: none; transition: opacity .2s ease-out; }
.shade-card:hover::after, .shade-card:focus-visible::after { opacity: .4; }
.shade-card span { position: absolute; z-index: 1; bottom: 12px; left: 12px; padding: 8px 12px; background: #102e6c; }
.shade-card:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .shade-card::after { transition: none; } }
</style>

Оверлей меняет прозрачность, а подпись остается в разметке. Поэтому название доступно без наведения, включая сенсорные устройства.

Подпись, выезжающая из-под края

<a class='caption-card' href='#caption-details'>
  <span class='caption-viewport'><img src='photo.jpg' alt='Горный маршрут' /><span class='caption-slide' aria-hidden='true'>Посмотреть маршрут</span></span>
  <span class='caption-name'>Посмотреть маршрут</span>
</a>
<p id='caption-details'>Описание маршрута доступно по ссылке.</p>
<style>
.caption-card { display: block; width: min(100%, 320px); color: #12336e; }
.caption-viewport { position: relative; display: block; overflow: hidden; }
.caption-viewport img { display: block; width: 100%; height: auto; }
.caption-slide { position: absolute; left: 0; right: 0; bottom: 0; padding: 12px; background: #102e6c; color: white; transform: translateY(100%); transition: transform .2s ease-out; }
.caption-name { display: block; padding: 12px 0; }
.caption-card:hover .caption-slide, .caption-card:focus-visible .caption-slide { transform: translateY(0); }
.caption-card:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .caption-slide { transition: none; } }
</style>

Основное название ссылки всегда видно. Декоративная копия выезжает на 100% своей высоты и не содержит единственную цену или кнопку.

Переворот карточки

<a class='flip-card' href='#flip-details'>
  <span class='flip-inner'><span class='flip-front'><img src='photo.jpg' alt='Горный маршрут' /><span>Горный маршрут</span></span><span class='flip-back' aria-hidden='true'>Горный маршрут – открыть описание</span></span>
</a>
<p id='flip-details'>Описание маршрута доступно без переворота карточки.</p>
<style>
.flip-card { display: block; width: min(100%, 320px); height: 200px; perspective: 800px; color: white; }
.flip-inner { position: relative; display: block; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform .3s ease-out; }
.flip-front, .flip-back { position: absolute; inset: 0; display: grid; place-items: center; backface-visibility: hidden; border-radius: 12px; overflow: hidden; }
.flip-front { background: #1746a2; } .flip-front img { width: 100%; height: 100%; object-fit: cover; }
.flip-front span { position: absolute; bottom: 12px; padding: 8px; background: #102e6c; }
.flip-back { padding: 20px; background: #102e6c; transform: rotateY(180deg); }
.flip-card:hover .flip-inner, .flip-card:focus-visible .flip-inner { transform: rotateY(180deg); }
.flip-card:focus-visible { outline: 3px solid #111; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .flip-inner { transition: none; transform: none !important; } }
</style>

perspective задает глубину, а rotateY(180deg) показывает обратную сторону. Ссылка должна открывать описание без обязательного переворота карточки.

Эффекты для иконок и SVG

SVG-иконки реагируют через рисующуюся обводку, смену заливки или поворот. Эффект должен оставаться декоративным: название действия задается кнопкой и aria-label, поэтому оно доступно без ориентации на изображение.

Рисующаяся обводка

<button class='draw-icon' type='button' aria-label='Выбрать элемент'>
  <svg viewBox='0 0 40 40' aria-hidden='true'><circle class='draw-ring' cx='20' cy='20' r='16' pathLength='100' /><path d='m13 20 5 5 9-10' fill='none' stroke='currentColor' stroke-width='3' /></svg>
</button>
<style>
.draw-icon { padding: 8px; border: 1px solid #aab8d0; border-radius: 8px; background: white; color: #1746a2; cursor: pointer; }
.draw-icon svg { display: block; width: 40px; height: 40px; }
.draw-ring { fill: none; stroke: currentColor; stroke-width: 2; stroke-dasharray: 100; stroke-dashoffset: 100; transition: stroke-dashoffset .25s ease-out; }
.draw-icon:hover .draw-ring, .draw-icon:focus-visible .draw-ring, .draw-icon:active .draw-ring { stroke-dashoffset: 0; }
.draw-icon:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .draw-ring { transition: none; } }
</style>

Атрибут pathLength='100' упрощает расчет штрихов. Значение stroke-dashoffset: 0 показывает весь контур.

Смена заливки

<button class='fill-icon' type='button' aria-label='Добавить в избранное'>
  <svg viewBox='0 0 40 40' aria-hidden='true'><path d='m20 4 5 10 11 2-8 8 2 12-10-6-10 6 2-12-8-8 11-2z' /></svg>
</button>
<style>
.fill-icon { padding: 8px; border: 1px solid #aab8d0; border-radius: 8px; background: white; cursor: pointer; }
.fill-icon svg { display: block; width: 40px; height: 40px; }
.fill-icon path { fill: #dce4f5; stroke: #12336e; transition: fill .2s ease-out; }
.fill-icon:hover path, .fill-icon:focus-visible path, .fill-icon:active path { fill: #1746a2; }
.fill-icon:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .fill-icon path { transition: none; } }
</style>

Селектор обращается к конкретному контуру SVG. Наведение показывает отклик, но не сохраняет выбор; постоянное состояние потребует логики кнопки и aria-pressed.

Поворот иконки

<button class='rotate-icon' type='button' aria-label='Открыть настройки'>
  <svg viewBox='0 0 40 40' aria-hidden='true'><path d='M20 5v30M5 20h30M9 9l22 22M9 31 31 9' fill='none' stroke='currentColor' stroke-width='5' /></svg>
</button>
<style>
.rotate-icon { padding: 8px; border: 1px solid #aab8d0; border-radius: 8px; background: white; color: #1746a2; cursor: pointer; }
.rotate-icon svg { display: block; width: 40px; height: 40px; transform-origin: center; transition: transform .2s ease-out; }
.rotate-icon:hover svg, .rotate-icon:focus-visible svg { transform: rotate(30deg); }
.rotate-icon:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .rotate-icon svg { transition: none; transform: none !important; } }
</style>

Поворот на 30 градусов подходит для декоративного отклика. Название действия остается в aria-label, поэтому смысл не зависит от изображения.

Направление курсора (direction-aware)

Чистый CSS не получает координаты входа указателя. Четыре зоны дают приближение, а точное направление входа и выхода требует JavaScript. CSS-вариант проще поддерживать, JavaScript-вариант добавляет обработчики событий и расчет геометрии.

CSS-вариант с четырьмя зонами

<a class='zones-card' href='#zones-details'>
  <span class='zone zone-top' aria-hidden='true'></span><span class='zone zone-right' aria-hidden='true'></span><span class='zone zone-bottom' aria-hidden='true'></span><span class='zone zone-left' aria-hidden='true'></span>
  <span class='zones-title'>Открыть проект</span>
</a>
<p id='zones-details'>Описание проекта.</p>
<style>
.zones-card { position: relative; display: grid; place-items: center; width: min(100%, 320px); height: 200px; overflow: hidden; background: #dcecff; color: white; }
.zones-card::after { content: ''; position: absolute; inset: 0; z-index: 1; background: #1746a2; opacity: 0; pointer-events: none; }
.zones-title { position: relative; z-index: 2; padding: 10px; background: #102e6c; pointer-events: none; }
.zone { display: none; position: absolute; inset: 0; z-index: 3; }
.zone-top { clip-path: polygon(0 0, 100% 0, 50% 50%); } .zone-right { clip-path: polygon(100% 0, 100% 100%, 50% 50%); } .zone-bottom { clip-path: polygon(0 100%, 100% 100%, 50% 50%); } .zone-left { clip-path: polygon(0 0, 0 100%, 50% 50%); }
.zones-card:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
@media (hover: hover) and (pointer: fine) {
  @supports selector(:has(*)) {
    .zone { display: block; }
    .zones-card:has(.zone:hover)::after { opacity: 1; animation: zone-in .2s ease-out; }
    .zones-card:has(.zone-top:hover)::after { animation-name: zone-top-in; } .zones-card:has(.zone-right:hover)::after { animation-name: zone-right-in; } .zones-card:has(.zone-bottom:hover)::after { animation-name: zone-bottom-in; } .zones-card:has(.zone-left:hover)::after { animation-name: zone-left-in; }
  }
}
@keyframes zone-in { from { transform: translateY(-100%); } to { transform: none; } }
@keyframes zone-top-in { from { transform: translateY(-100%); } to { transform: none; } } @keyframes zone-right-in { from { transform: translateX(100%); } to { transform: none; } } @keyframes zone-bottom-in { from { transform: translateY(100%); } to { transform: none; } } @keyframes zone-left-in { from { transform: translateX(-100%); } to { transform: none; } }
@media (prefers-reduced-motion: reduce) { .zones-card::after { animation: none !important; } }
</style>

Четыре элемента определяют зону указателя, но CSS не запоминает сторону входа. Условие (hover: hover) and (pointer: fine) оставляет этот прием для точного указателя, а @supports защищает вариант от отсутствия :has().

JavaScript-вариант с ближайшим краем

<a class='direction-card' href='#direction-details'><span class='direction-panel' aria-hidden='true'></span><span class='direction-title'>Открыть проект</span></a>
<p id='direction-details'>Материалы проекта.</p>
<style>
.direction-card { position: relative; display: grid; place-items: center; width: min(100%, 320px); height: 200px; overflow: hidden; background: #dcecff; color: white; }
.direction-panel { position: absolute; inset: 0; background: #1746a2; pointer-events: none; transform: translateY(-101%); transition: transform .2s ease-out; }
.direction-title { position: relative; padding: 10px; background: #102e6c; }
.direction-card:focus-visible { outline: 3px solid #111; outline-offset: 3px; } .direction-card:focus-visible .direction-panel { transform: translate(0, 0) !important; }
@media (prefers-reduced-motion: reduce) { .direction-panel { transition: none !important; } }
</style>
<script>
const cards = document.querySelectorAll('.direction-card');
cards.forEach(card => {
  const panel = card.querySelector('.direction-panel');
  const edge = event => { const r = card.getBoundingClientRect(); const x = event.clientX - r.left; const y = event.clientY - r.top; const d = [y, r.width - x, r.height - y, x]; const i = d.indexOf(Math.min(...d)); return ['translateY(-101%)','translateX(101%)','translateY(101%)','translateX(-101%)'][i]; };
  card.addEventListener('pointerenter', event => { panel.style.transition = 'none'; panel.style.transform = edge(event); panel.getBoundingClientRect(); requestAnimationFrame(() => { panel.style.transition = ''; panel.style.transform = 'translate(0, 0)'; }); });
  card.addEventListener('pointerleave', event => { panel.style.transform = edge(event); });
});
</script>

Функция сравнивает расстояния до четырех краев карточки. Скрипт запускает панель со стороны входа и возвращает ее к стороне выхода. При отключенном JavaScript ссылка и подпись сохраняют работу.

Что анимировать можно, а что нельзя

Кадр страницы проходит через layout, paint и composite. Берите свойства, которые меняют уже нарисованный элемент, особенно когда эффект занимает крупную область.

СвойствоЧто пересчитывает браузерПодходит для hover
transformОбычно compositeДа, для сдвига, зума и поворота
opacityОбычно compositeДа, для готового слоя
width, heightLayout и последующие этапыИзбегайте непрерывного изменения
top, left, marginLayout и последующие этапыДля сдвига берите transform
box-shadowЗависит от размера и результатаИспользуйте на небольших элементах
background-color, border-color, fillPaintПодходит для небольших элементов
stroke-dashoffsetPaint SVGПодходит для небольшого SVG

transform и opacity часто позволяют браузеру обновлять композитный слой без layout и paint. Итог зависит от размера элемента и его окружения.

will-change заранее сообщает браузеру о предстоящем изменении и расходует память. Используйте его только для элемента с найденной проблемой производительности, а не для всех кнопок страницы.

Тач-устройства

Сенсорный экран не дает устойчивого состояния наведения. Основное действие и подпись должны работать без :hover.

<button class='touch-btn' type='button'>Продолжить</button>
<style>
.touch-btn { padding: 12px 24px; border: 2px solid #1746a2; border-radius: 8px; background: white; color: #12336e; font: inherit; cursor: pointer; transition: background-color .2s ease-out; }
@media (hover: hover) and (pointer: fine) { .touch-btn:hover { background: #dcecff; } }
.touch-btn:focus-visible { outline: 3px solid #111; outline-offset: 3px; }
.touch-btn:active { background: #c1d7ff; }
@media (prefers-reduced-motion: reduce) { .touch-btn { transition: none; } }
</style>

Медиазапрос оставляет декоративное наведение для точного указателя. Фокус клавиатуры и активное состояние дают отклик при других способах ввода.

Доступность

Наведение дополняет интерфейс. Название, цена и основное действие должны быть доступны до движения указателя.

  • Сохраняйте заметный фокус через :focus-visible.
  • Берите нативные кнопки и ссылки вместо декоративных div.
  • Проверяйте контраст текста во всех состояниях.
  • Не прячьте единственную цену или кнопку на обратной стороне карточки.
  • Дублируйте hover-отклик состоянием фокуса, если он помогает понять интерфейс.

Медиазапрос prefers-reduced-motion отключает переходы для людей, которые предпочитают уменьшенное движение. Для пространственных эффектов можно убрать движение полностью, сохранив цвет и фокус.

Частые ошибки в CSS hover-эффектах

Ошибки в hover часто связаны с местом размещения transition, попыткой анимировать display и несоответствием области отклика ссылке. Отдельного внимания требуют длительность перехода и необязательные свойства в transition: all.

Transition записан только в :hover

Когда transition стоит только в состоянии наведения, возврат после ухода указателя может стать мгновенным. Перенесите правило в базовое состояние.

.example { background: #1746a2; transition: background-color .2s ease-out; }
.example:hover { background: #102e6c; }

Попытка анимировать display

Значения none и block не создают промежуточных кадров. Для декоративного появления меняйте opacity или transform, а настоящие элементы управления дополнительно убирайте из взаимодействия.

Отклик занимает всю карточку, а ссылка только заголовок

Большая подсветка обещает действие на всей области. Если карточка ведет в одно место, сделайте всю карточку ссылкой. При нескольких действиях оставьте отдельные кнопки и ссылки.

Отклик длится слишком долго

Для обычного hover используйте конкретную длительность около .2s. Переходы дольше .3s часто задерживают простой отклик.

Transition: all меняет лишнее

Перечисляйте свойства явно, например transform .2s ease-out, opacity .2s ease-out. Так ширина и отступы не получат случайную анимацию.

Что выбрать под задачу

ЗадачаЭффектЧто оставить без hover
Основная кнопкаЗаливка или смена фонаНазвание действия и фокус
Текстовая ссылкаПодчеркиваниеОтличие ссылки от текста
Карточка товараЗатемнение с подписьюНазвание, цену и покупку
Иллюстрация портфолиоЗум или декоративная подписьСсылку на проект
Иконка действияSVG-обводка или смена заливкиДоступное название кнопки
Декоративная карточкаПереворот или direction-awareПонятное статичное состояние

Совет: сначала добейтесь понятного действия без анимации. Затем добавьте один эффект и проверьте его мышью, клавиатурой и касанием.

Комментарии

Оставьте свой комментарий
ВБ
Владимир Б.

круто

А
Алексей

можно лучше

Л
Лейсан

здорово! просто нет слов! как раз занимаюсь оформлением сайта..буду экспериментировать! Спасибо!

ЕХ
Евгений Х.

ЭТО СУПЕР