Привет, я фронтенд‑разработчик и сегодня хочу поговорить о псевдоэлементах before и after. Думаю, с ними знаком практически каждый, кто хотя бы немного работал с CSS. И вроде бы всё просто:
.element::before { /* что-то до содержимого */ } .element::after { /* что-то после содержимого */ }
У одного элемента есть всего два таких псевдоэлемента: before и after. И на этом вроде бы всё. Но что, если попробовать посмотреть на них немного иначе?
Вместо того, чтобы воспринимать before и after просто как способ добавить ещё один визуальный элемент, можно использовать их вместе с другими возможностями CSS: box‑shadow, несколькими background, clip‑path, масками, mix‑blend‑mode, трансформациями и анимациями.
И тогда оказывается, что двух псевдоэлементов вполне может хватить для создания довольно сложных визуальных эффектов, причём без добавления лишних элементов в HTML.
Оговорюсь, что базовое применение before и after уже подробно разобрано в многочисленных материалах. Поэтому повторять, что before добавляется перед содержимым, а after — после, и собирать очередную коллекцию из простых декоративных линий я не буду. Вместо этого предлагаю посмотреть насколько далеко можно зайти, если использовать эти два элемента с небольшой долей креатива и с интересными приёмами.
Поехали.
1. Несколько объектов через box‑shadow
Начнём с простого примера. Представим небольшой индикатор, состоящий из нескольких одинаковых точек:
.signal::before { content: ""; position: absolute; top: 50%; left: 0; width: 8px; height: 8px; border-radius: 50%; background: #fff; transform: translateY(-50%); box-shadow: 16px 0 #fff, 32px 0 #fff, 48px 0 #fff; }
В HTML при этом находится всего один элемент:
<div class="signal"></div>
Но визуально мы получаем сразу четыре точки:

Происходит это за счёт box-shadow.
Обычно тень используется для создания визуального эффекта вокруг элемента. Но если убрать размытие и задать нулевые смещения по вертикали, тень можно использовать как своеобразную копию исходного элемента.
То есть вместо четырёх элементов
<span></span> <span></span> <span></span> <span></span>
мы получили четыре визуальных объекта из одного псевдоэлемента.
И здесь появляется важный момент: box-shadow хорошо работает именно тогда, когда нам нужны одинаковые копии.
Если мы захотим сделать из этих точек индикатор уровня сигнала, то одного box-shadow уже недостаточно. Каждая тень является копией исходного элемента, поэтому независимо задать высоту каждой копии таким способом нельзя.
Пример с 4 точками.
<!DOCTYPE html> <html> <head> <style> * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b0b0d; } .signal { position: relative; width: 64px; height: 20px; } .signal::before { content: ""; position: absolute; top: 50%; left: 0; width: 8px; height: 8px; border-radius: 50%; background: #fff; transform: translateY(-50%); box-shadow: 16px 0 #fff, 32px 0 #fff, 48px 0 #fff; } </style> </head> <body> <div class="signal"></div> </body> </html>
2. Несколько графических слоёв через background
В предыдущем примере мы использовали box-shadow, чтобы из одного псевдоэлемента получить несколько одинаковых объектов, но box-shadow подходит далеко не для всего.
А что если нам нужно собрать из одного before сразу несколько разных декоративных элементов?
Например, у нас есть карточка с текстом, а вокруг него хочется добавить фоновый градиент, свечение, диагональную полосу и небольшие декоративные точки.
Создавать для каждого элемента отдельный div или span необязательно.
Before может выступить здесь не отдельной фигурой, а целым графическим слоем.
Возьмём обычную карточку:
<div class="card"> <div class="card__content"> <h2 class="card__title">Скидка 30%</h2> <p class="card__text">Только до конца недели</p> </div> </div>
А всю декоративную часть вынесем в before:
.card { position: relative; width: 320px; height: 180px; padding: 24px; box-sizing: border-box; overflow: hidden; border-radius: 20px; background: #222; color: #fff; } .card::before { content: ""; position: absolute; inset: 0; background: radial-gradient( circle at 85% 20%, rgba(255, 255, 255, .8) 0 3px, transparent 4px ), radial-gradient( circle at 75% 75%, rgba(255, 255, 255, .3) 0 60px, transparent 61px ), linear-gradient( 135deg, transparent 0 45%, rgba(255, 255, 255, .08) 45% 55%, transparent 55% ), linear-gradient(135deg, #222, #555); }
Здесь по‑прежнему только один before, но его background состоит сразу из нескольких слоёв. Первый radial-gradient создаёт небольшую декоративную точку. Второй — мягкое свечение. Linear-gradient формирует диагональную полосу. Последний градиент отвечает за основной фон.
При этом каждый слой можно независимо позиционировать, менять его размер, прозрачность и форму.
Получается, что один before становится целой композицией, состоящей из нескольких независимых графических слоёв.
И здесь появляется практический смысл такого подхода. Если эти элементы нужны только для визуального оформления, добавлять ради них несколько div в HTML нет необходимости. Они не имеют смысла, не участвуют в структуре документа и существуют исключительно ради внешнего вида.
Вместо
<div class="card"> <div class="background"></div> <div class="glow"></div> <div class="line"></div> <div class="dot"></div> ... </div>
мы можем оставить один before, который полностью отвечает за декоративный фон.
То есть background позволяет пойти ещё на один уровень дальше: один псевдоэлемент может быть не одним объектом, а целым набором графических слоёв.

<!DOCTYPE html> <html> <head> <style> body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f3f3f3; font-family: Arial, sans-serif; } .card { position: relative; width: 320px; height: 180px; padding: 24px; box-sizing: border-box; overflow: hidden; border-radius: 20px; background: #222; color: #fff; } .card::before { content: ""; position: absolute; inset: 0; background: radial-gradient( circle at 85% 20%, rgba(255, 255, 255, .8) 0 3px, transparent 4px ), radial-gradient( circle at 75% 75%, rgba(255, 255, 255, .3) 0 60px, transparent 61px ), linear-gradient( 135deg, transparent 0 45%, rgba(255, 255, 255, .08) 45% 55%, transparent 55% ), linear-gradient(135deg, #222, #555); } .card__content { position: relative; z-index: 1; } .card__title { margin: 0 0 10px; font-size: 28px; } .card__text { margin: 0; opacity: .7; } </style> </head> <body> <div class="card"> <div class="card__content"> <h2 class="card__title">Скидка 30%</h2> <p class="card__text">Только до конца недели</p> </div> </div> </body> </html>
3. Сложная фигура через clip‑path
До этого мы увеличивали количество объектов внутри одного псевдоэлемента. Теперь попробуем увеличить сложность формы самого объекта.
Представим обычную карточку товара. Нам нужно добавить на неё бейдж со скидкой, но хочется, чтобы он имел нестандартную форму, например, как флажок:

Можно собрать такую форму из нескольких HTML‑элементов, но если этот элемент нужен только для оформления, проще снова использовать before.
Изначально псевдоэлемент — обычный прямоугольник:
.product::before { content: ""; position: absolute; width: 120px; height: 42px; background: #000; } Теперь изменим его форму с помощью clip-path: .product::before { content: ""; position: absolute; top: 20px; right: -60px; width: 120px; height: 42px; background: #000; clip-path: polygon( 0 0, 100% 0, 85% 50%, 100% 100%, 0 100% ); }
Сам элемент при этом никуда не исчезает и не становится каким‑то новым CSS‑типом. У него по‑прежнему есть прямоугольная область 120 × 42 px. clip-path просто определяет, какая часть этой области должна остаться видимой.
В polygon() мы задаём координаты точек, соединяя которые браузер получает итоговую форму:
clip-path: polygon( 0 0, 100% 0, 85% 50%, 100% 100%, 0 100% );
Изменив несколько координат, можно получить уже совершенно другую геометрию:
clip-path: polygon( 0 0, 100% 0, 80% 100%, 20% 100% );
Всё просто: берём один before и используем его как заготовку, форму которой можно практически полностью контролировать с помощью CSS.
Причём clip-path не ограничивает нас только геометрией. После создания формы к тому же псевдоэлементу можно применять градиенты, transform, анимации и другие CSS‑механизмы.
Пример с флагом:
<!DOCTYPE html> <html> <head> <style> body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f3f3f3; font-family: Arial, sans-serif; } .product { position: relative; width: 320px; height: 200px; padding: 24px; box-sizing: border-box; border-radius: 16px; background: #fff; } .product__title { margin: 0 0 8px; font-size: 24px; } .product__text { margin: 0; color: #777; } .product::before { content: ""; position: absolute; top: 20px; right: -60px; width: 120px; height: 42px; background: #000; clip-path: polygon( 0 0, 100% 0, 85% 50%, 100% 100%, 0 100% ); } .product::after { content: "-30%"; position: absolute; top: 20px; right: 12px; height: 42px; display: flex; align-items: center; color: #fff; font-weight: 700; } </style> </head> <body> <div class="product"> <h2 class="product__title">Новые наушники</h2> <p class="product__text">Беспроводная модель</p> </div> </body> </html>
4. Маска вместо clip‑path
В предыдущем разделе мы использовали clip‑path, чтобы изменить форму обычного прямоугольника, но у такого подхода есть ограничение: граница получившейся области остаётся чёткой.
А что делать, если нам нужен не просто вырезанный участок, а плавный переход в прозрачность? Здесь поможет маска. Возьмём обычную кнопку и добавим к ней декоративный слой:
.button::before { content: ""; position: absolute; top: -50%; left: -100%; width: 60%; height: 200%; background: #fff; opacity: .35; transform: rotate(20deg); }
Пока это просто белый прямоугольник, повернутый под углом.
Теперь добавим маску:
.button::before { content: ""; position: absolute; top: -50%; left: -100%; width: 60%; height: 200%; background: #fff; opacity: .35; transform: rotate(20deg); mask-image: linear-gradient( to right, transparent, #000 40%, #000 60%, transparent ); }
Маска определяет, насколько каждая часть псевдоэлемента должна быть видимой.
В центре слой остаётся полностью видимым, а к краям постепенно становится прозрачным. В результате обычный прямоугольник превращается в световой блик.
А теперь добавим ему движение:
.button::before { transition: left .7s ease; } .button:hover::before { left: 140%; }
При наведении псевдоэлемент проходит через всю кнопку, создавая эффект движущегося блика. Изи. С помощью маски можно сделать границу не резкой, а постепенно исчезающей, как в фотошопе.
Причём сам HTML не меняется:
<button class="button"> <span class="button__text">Оплатить</span> </button>
Нам не понадобился отдельный элемент для блика. Вместо этого before уже является самостоятельным графическим слоем, а маска определяет его видимую область.
На стоп‑кадре выглядит так:

<!DOCTYPE html> <html> <head> <style> body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f3f3f3; font-family: Arial, sans-serif; } .button { position: relative; width: 220px; height: 64px; overflow: hidden; border: 0; border-radius: 14px; background: #111; color: #fff; font-size: 18px; font-weight: 600; cursor: pointer; } .button::before { content: ""; position: absolute; top: -50%; left: -100%; width: 60%; height: 200%; background: #fff; opacity: .35; transform: rotate(20deg); mask-image: linear-gradient( to right, transparent, #000 40%, #000 60%, transparent ); transition: left .7s ease; } .button:hover::before { left: 140%; } .button__text { position: relative; z-index: 1; } </style> </head> <body> <button class="button"> <span class="button__text">Оплатить</span> </button> </body> </html>
5. Псевдоэлемент как отдельный слой через mix‑blend‑mode
До этого мы в основном управляли самим псевдоэлементом: меняли его форму, фон, прозрачность и положение, но before может взаимодействовать не только с настройками самого себя. С помощью mix‑blend‑mode можно определить, как его содержимое будет смешиваться со слоями, которые находятся под ним.
Возьмём обычную карточку и добавим на неё цветное пятно:
.card::before { content: ""; position: absolute; width: 180px; height: 180px; top: -40px; left: -40px; border-radius: 50%; background: #ff4d00; mix-blend-mode: difference; filter: blur(10px); }
Само по себе это просто оранжевый круг. Но mix-blend-mode: difference меняет принцип его отображения.
Псевдоэлемент не просто рисуется поверх карточки. Его пиксели смешиваются с фоном, поэтому итоговый цвет зависит от того, с каким цветом он пересекается.
Теперь добавим небольшую анимацию:
.card:hover::before { transform: translate(220px, 100px) scale(1.3); background: #00e5ff; }
При наведении тот же самый before перемещается, увеличивается и меняет цвет. При этом нам не приходится создавать дополнительные элементы для разных состояний.
В итоге один псевдоэлемент одновременно выполняет несколько задач:
создаёт отдельное цветовое пятно;
смешивается с фоном карточки;
размывается через filter;
перемещается через transform;
меняет цвет при hover.
И всё это остаётся одним before. Вот так это выглядит на стоп кадре опять же.

<!DOCTYPE html> <html> <head> <style> * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0d0d0d; font-family: Arial, sans-serif; } .card { position: relative; width: 360px; height: 220px; padding: 28px; overflow: hidden; border-radius: 24px; background: linear-gradient(135deg, #171717, #343434); color: #fff; cursor: pointer; } .card::before { content: ""; position: absolute; width: 180px; height: 180px; top: -40px; left: -40px; border-radius: 50%; background: #ff4d00; mix-blend-mode: difference; filter: blur(10px); transition: transform .6s ease, background .6s ease; } .card:hover::before { transform: translate(220px, 100px) scale(1.3); background: #00e5ff; } .card__content { position: relative; z-index: 1; } .card__label { display: inline-block; margin-bottom: 24px; font-size: 12px; font-weight: 700; letter-spacing: .15em; } .card__title { margin: 0 0 12px; font-size: 28px; } .card__text { max-width: 240px; margin: 0; line-height: 1.5; opacity: .7; } </style> </head> <body> <div class="card"> <div class="card__content"> <span class="card__label">PRO</span> <h2 class="card__title"> Frontend Developer </h2> <p class="card__text"> Создавай интерфейсы, которые запоминаются </p> </div> </div> </body> </html>
6. Loader только из before + after
Теперь попробуем пойти дальше и сделаем loader. В обычной реализации внутри него можно было бы создать несколько span, а уже каждому элементу назначить свою анимацию или Lotty‑библиотеку какую‑нибудь. Но HTML будет максимально скучным, а сама магия скрывается в псевдоэлементах.
Первый псевдоэлемент превратим в большую светящуюся точку:
.loader::before { width: 18px; height: 18px; background: #fff; box-shadow: 0 0 12px rgba(255, 255, 255, .8), 0 0 30px rgba(255, 255, 255, .4); animation: orbit 1.4s linear infinite, pulse 1.4s ease-in-out infinite; }
А второй сделаем немного меньше:
.loader::after { width: 10px; height: 10px; background: #777; box-shadow: 0 0 10px rgba(255, 255, 255, .5); animation: orbit-reverse 1.4s linear infinite, pulse-small 1.4s ease-in-out infinite; }
Дальше анимируем. Большая точка не просто перемещается из одной координаты в другую. Она вращается вокруг центра loader:
@keyframes orbit { 0% { transform: rotate(0deg) translateX(30px) rotate(0deg); } 100% { transform: rotate(360deg) translateX(30px) rotate(-360deg); } }
Вторая точка делает то же самое, но в обратном направлении:
@keyframes orbit-reverse { 0% { transform: rotate(180deg) translateX(30px) rotate(-180deg); } 100% { transform: rotate(-180deg) translateX(30px) rotate(180deg); } }
Одновременно точки меняют свой размер и прозрачность:
@keyframes pulse { 0%, 100% { scale: .65; opacity: .4; } 50% { scale: 1; opacity: 1; } }
У второй точки собственный ритм:
@keyframes pulse-small { 0%, 100% { scale: 1; opacity: .5; } 50% { scale: .6; opacity: 1; } }
В результате два псевдоэлемента одновременно имеют разные размеры, направления движения и изменения масштаба и прозрачности.
Стоп‑кадр:

Код полностью:
<!DOCTYPE html> <html <head> <style> * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b0b0d; font-family: Arial, sans-serif; } .loader { position: relative; width: 90px; height: 90px; } .loader::before, .loader::after { content: ""; position: absolute; top: 50%; left: 50%; width: 18px; height: 18px; margin: -9px; border-radius: 50%; } .loader::before { background: #fff; box-shadow: 0 0 12px rgba(255, 255, 255, .8), 0 0 30px rgba(255, 255, 255, .4); animation: orbit 1.4s linear infinite, pulse 1.4s ease-in-out infinite; } .loader::after { width: 10px; height: 10px; margin: -5px; background: #777; box-shadow: 0 0 10px rgba(255, 255, 255, .5); animation: orbit-reverse 1.4s linear infinite, pulse-small 1.4s ease-in-out infinite; } @keyframes orbit { 0% { transform: rotate(0deg) translateX(30px) rotate(0deg); } 100% { transform: rotate(360deg) translateX(30px) rotate(-360deg); } } @keyframes orbit-reverse { 0% { transform: rotate(180deg) translateX(30px) rotate(-180deg); } 100% { transform: rotate(-180deg) translateX(30px) rotate(180deg); } } @keyframes pulse { 0%, 100% { scale: .65; opacity: .4; } 50% { scale: 1; opacity: 1; } } @keyframes pulse-small { 0%, 100% { scale: 1; opacity: .5; } 50% { scale: .6; opacity: 1; } } </style> </head> <body> <div class="loader"></div> </body> </html>
7. Сложная анимация из двух псевдоэлементов
Тот же принцип можно использовать для создания анимации, которая воспринимается уже как одно целое действие.
Возьмём обычную кнопку:
<button class="button"> Наведи на меня </button>
До наведения она выглядит совершенно обычно. Но при hover из неё начинают появляться две разные фигуры: ромб и кольцо.
Ромб создадим через before:
.button::before { content: ""; position: absolute; top: 50%; left: 50%; width: 14px; height: 14px; background: #fff; opacity: 0; transform: translate(-50%, -50%) rotate(45deg) scale(.2); transition: transform .7s cubic-bezier(.2, .8, .2, 1), opacity .25s ease; }
А кольцо — через after:
.button::after { content: ""; position: absolute; top: 50%; left: 50%; width: 22px; height: 22px; border: 2px solid #fff; border-radius: 50%; opacity: 0; transform: translate(-50%, -50%) scale(.2); transition: transform .7s cubic-bezier(.2, .8, .2, 1) .12s, opacity .25s ease .12s; }
Теперь зададим им разные направления движения. Ромб будет уходить влево‑вверх:
.button:hover::before { opacity: 1; transform: translate(-50%, -50%) translate(-115px, -34px) rotate(180deg) scale(1.4); }
А кольцо — вправо‑вниз:
.button:hover::after { opacity: 1; transform: translate(-50%, -50%) translate(115px, 34px) rotate(180deg) scale(1.6); }
У каждого псевдоэлемента может быть собственная комбинация преобразований:
transform: translate(...) rotate(...) scale(...);
Причём отличаются не только конечные координаты. У элементов могут быть разные задержки, длительность, функция плавности и даже совершенно разные типы анимации.
Псевдоэлементы могут быть не только дополнительной графикой, но и самостоятельными участниками анимации.
Стоп‑кадр:

Код полностью:
<!DOCTYPE html> <html <head> <style> * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b0b0d; font-family: Arial, sans-serif; } .button { position: relative; width: 220px; height: 64px; border: 0; border-radius: 32px; background: #fff; color: #111; font-size: 16px; font-weight: 600; cursor: pointer; isolation: isolate; } .button::before, .button::after { content: ""; position: absolute; top: 50%; left: 50%; pointer-events: none; } .button::before { width: 14px; height: 14px; background: #fff; opacity: 0; transform: translate(-50%, -50%) rotate(45deg) scale(.2); transition: transform .7s cubic-bezier(.2, .8, .2, 1), opacity .25s ease; } /* Кольцо */ .button::after { width: 22px; height: 22px; border: 2px solid #fff; border-radius: 50%; opacity: 0; transform: translate(-50%, -50%) scale(.2); transition: transform .7s cubic-bezier(.2, .8, .2, 1) .12s, opacity .25s ease .12s; } .button:hover::before { opacity: 1; transform: translate(-50%, -50%) translate(-115px, -34px) rotate(180deg) scale(1.4); } .button:hover::after { opacity: 1; transform: translate(-50%, -50%) translate(115px, 34px) rotate(180deg) scale(1.6); } </style> </head> <body> <button class="button"> Наведи на меня </button> </body> </html>
8. Псевдоэлемент как дополнительный элемент интерфейса
До этого мы использовали before и after для создания сложных фигур, слоёв и анимаций.
Но есть ещё одно очень практичное применение — это добавление небольшого элемента интерфейса без изменения HTML‑разметки.
Представим обычный аватар пользователя. Нам нужно показать рядом с ним статус в сети.
Самый очевидный вариант — добавить ещё один элемент:
.avatar::after { content: ""; position: absolute; right: -2px; bottom: -2px; width: 16px; height: 16px; border: 3px solid #18181b; border-radius: 50%; background: #fff; }
Поскольку .avatar имеет position: relative, псевдоэлемент позиционируется относительно него.
Особенно интересно здесь значение:
right: -2px; bottom: -2px;
Индикатор немного выходит за границы аватара. При этом он не увеличивает размеры самого аватара и не влияет на расположение соседних элементов.
То есть визуально у нас появился ещё один объект:

А в DOM по‑прежнему находится только:
<div class="avatar"></div>
Причём таким способом можно делать не только индикатор онлайн. Тот же подход подходит для счётчика непрочитанных сообщений, бейджа NEW, отметки PRO, уведомления на иконке или любого другого небольшого элемента, который является частью уже существующего компонента.
Идея в том, что псевдоэлемент не обязательно должен быть самостоятельным компонентом интерфейса. Он может быть небольшой визуальной частью существующего элемента, для которой нет смысла добавлять новую смысловую разметку.
Код полностью:
<!DOCTYPE html> <html> <head> <style> * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b0b0d; font-family: Arial, sans-serif; } .profile { display: flex; align-items: center; gap: 16px; padding: 20px 24px; border-radius: 18px; background: #18181b; color: #fff; } .avatar { position: relative; width: 64px; height: 64px; flex-shrink: 0; border-radius: 50%; background: linear-gradient( 135deg, #777, #222 ); } .avatar::after { content: ""; position: absolute; right: -2px; bottom: -2px; width: 16px; height: 16px; border: 3px solid #18181b; border-radius: 50%; background: #fff; } .profile__name { margin: 0 0 5px; font-size: 18px; } .profile__status { margin: 0; color: #888; font-size: 14px; } </style> </head> <body> <div class="profile"> <div class="avatar"></div> <div> <h2 class="profile__name"> Дмитрий </h2> <p class="profile__status"> Сейчас в сети </p> </div> </div> </body> </html>
9. Невидимая зона взаимодействия
До этого мы использовали псевдоэлементы в основном для того, чтобы что‑то нарисовать, но они могут работать и наоборот — ничего не рисовать.
Возьмём довольно распространённый элемент интерфейса: небольшую иконку с дополнительной информацией. Например: Цена 2 490 ₽?.
При наведении на ? должен появляться tooltip с пояснением.
Сама иконка небольшая — всего 18 × 18 px. Если сделать область наведения ровно такого размера, пользователю придётся достаточно точно попасть курсором в неё, Но у нас уже есть before, и ему совершенно необязательно что‑либо отображать:
.info::before { content: ""; position: absolute; inset: -14px; }
Теперь псевдоэлемент занимает область гораздо больше самой иконки. При этом визуально ничего не изменилось. before полностью прозрачный и не имеет ни фона, ни границы.
Но поскольку эта область находится внутри.info, она расширяет область, на которой срабатывает: .info:hover
А сам tooltip мы можем разместить уже в after:
.info::after { content: "В стоимость уже включена доставка"; position: absolute; left: 50%; bottom: calc(100% + 14px); width: 220px; padding: 12px 14px; border-radius: 10px; background: #fff; color: #111; opacity: 0; pointer-events: none; transform: translateX(-50%) translateY(6px); transition: opacity .2s ease, transform .2s ease; }
И показываем его при наведении:
.info:hover::after { opacity: 1; transform: translateX(-50%) translateY(0); }
В итоге у одного элемента есть сразу два псевдоэлемента, но каждый выполняет совершенно разную роль. При этом в HTML для всего этого нужен только один элемент. Не нужны отдельные элементы для области наведения и самого tooltip.

Еще важно:
.info::after { pointer-events: none; }
Сам tooltip не должен перехватывать события мыши. Он является результатом наведения, а не самостоятельным интерактивным элементом. Если же tooltip должен содержать ссылки, кнопки или другой интерактивный контент, такой подход уже не подойдёт — тогда лучше использовать обычный HTML‑элемент.
Код полностью:
<!DOCTYPE html> <html > <head> <style> * { box-sizing: border-box; } body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0b0b0d; font-family: Arial, sans-serif; } .price { display: flex; align-items: center; gap: 8px; color: #fff; font-size: 18px; } .info { position: relative; width: 18px; height: 18px; border: 1px solid #777; border-radius: 50%; color: #aaa; font-size: 12px; font-weight: 700; display: grid; place-items: center; cursor: help; } .info::before { content: ""; position: absolute; inset: -14px; } .info::after { content: "В стоимость уже включена доставка"; position: absolute; left: 50%; bottom: calc(100% + 14px); width: 220px; padding: 12px 14px; border-radius: 10px; background: #fff; color: #111; font-size: 13px; font-weight: 400; line-height: 1.4; opacity: 0; pointer-events: none; transform: translateX(-50%) translateY(6px); transition: opacity .2s ease, transform .2s ease; } .info:hover::after { opacity: 1; transform: translateX(-50%) translateY(0); } </style> </head> <body> <div class="price"> <span>2 490 ₽</span> <span class="info">?</span> </div> </body> </html>
Заключение
before и after — это всего два псевдоэлемента. Но если перестать воспринимать их просто как «что‑то до» и «что‑то после» контента, возможностей становится гораздо больше. Box‑shadow, background, clip‑path, маски, mix‑blend‑mode, трансформации и анимации позволяют превращать эти два элемента в целые графические слои. Главное не пытаться заменить псевдоэлементами весь HTML. Их сила именно там, где визуально нужно много, а смысловой разметки минимум. Два псевдоэлемента — два слоя. А дальше всё зависит от того, что мы умеем с ними делать.