Ripple effect нужен не ради “красивой анимации”, а ради понятного отклика интерфейса. Пользователь нажал кнопку и сразу получил визуальный сигнал, что событие действительно обработалось.
Подключать большую UI-библиотеку только ради ряби обычно бессмысленно. Эффект достаточно простой, и его можно собрать на чистом React с парой styled-components. Важно только не оставлять placeholder-код и правильно считать координаты клика относительно контейнера.
- Из чего состоит ripple effect
- Стили контейнера и самой ряби
- Рабочий компонент
Ripple - Использование внутри кнопки
- Практические замечания
Обновлено 17 марта 2026: статья переписана под рабочий React 18 + TypeScript пример без placeholder-блоков и с корректным расчетом координат через
clientX/clientY.
Из чего состоит ripple effect
У эффекта всего три части:
- контейнер с
overflow: hidden, чтобы волна не выходила за границы кнопки; - круг, который появляется в точке клика;
- анимация, увеличивающая круг и плавно убирающая его прозрачностью.
Базовая HTML-структура выглядит так:
<button class="button">
<span class="ripple-layer">
<span class="ripple"></span>
</span>
</button>Но в React удобнее рендерить эти элементы из состояния, потому что позиция и размер ряби зависят от конкретного клика.
Стили контейнера и самой ряби
Ниже рабочий вариант на styled-components. Явно типизируем динамические пропсы, чтобы код не ломался на TypeScript-проекте.
// styles.ts
import styled, { keyframes } from "styled-components";
const rippleAnimation = keyframes`
to {
opacity: 0;
transform: scale(2);
}
`;
export const Button = styled.button<{ $background?: string }>`
position: relative;
overflow: hidden;
padding: 0.75rem 1.5rem;
border: 0;
border-radius: 999px;
background: ${({ $background = "tomato" }) => $background};
color: #fff;
cursor: pointer;
`;
export const RippleLayer = styled.span<{
$duration: number;
$color: string;
}>`
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
> span {
position: absolute;
display: block;
border-radius: 50%;
transform: scale(0);
opacity: 0.3;
background-color: ${({ $color }) => $color};
animation: ${rippleAnimation} ${({ $duration }) => $duration}ms linear;
}
`;Здесь главное не само наличие styled-components, а поведение:
overflow: hiddenудерживает волну внутри кнопки;border-radius: inheritсохраняет форму родителя;- размер и позиция ряби приходят через inline-стили из React-состояния.
Рабочий компонент Ripple
Теперь сам компонент. Он хранит массив рябей, считает размер по большей стороне контейнера и очищает DOM после завершения анимации.
// Ripple.tsx
import { MouseEvent, useEffect, useRef, useState } from "react";
import { RippleLayer } from "./styles";
type RippleItem = {
id: number;
x: number;
y: number;
size: number;
};
type RippleProps = {
duration?: number;
color?: string;
};
export function Ripple({
duration = 700,
color = "#ffffff",
}: RippleProps) {
const [ripples, setRipples] = useState<RippleItem[]>([]);
const nextId = useRef(0);
const addRipple = (event: MouseEvent<HTMLSpanElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
setRipples((current) => [
...current,
{ id: nextId.current++, x, y, size },
]);
};
useEffect(() => {
if (ripples.length === 0) {
return undefined;
}
const timeoutId = window.setTimeout(() => {
setRipples([]);
}, duration);
return () => window.clearTimeout(timeoutId);
}, [ripples, duration]);
return (
<RippleLayer
$duration={duration}
$color={color}
onMouseDown={addRipple}
aria-hidden="true"
>
{ripples.map((ripple) => (
<span
key={ripple.id}
style={{
top: ripple.y,
left: ripple.x,
width: ripple.size,
height: ripple.size,
}}
/>
))}
</RippleLayer>
);
}Ключевая деталь здесь в координатах. Для расчета позиции внутри контейнера удобнее использовать clientX и clientY вместе с getBoundingClientRect(). Тогда тебе не нужно вручную учитывать прокрутку страницы, как это часто случается с pageX и pageY.
Использование внутри кнопки
Остается просто вставить Ripple внутрь кнопки:
// App.tsx
import { Button } from "./styles";
import { Ripple } from "./Ripple";
export default function App() {
return (
<main>
<Button>
Save
<Ripple />
</Button>
<Button $background="#1f6feb">
Publish
<Ripple duration={900} color="#ffffff" />
</Button>
</main>
);
}Компонент остается маленьким, а кнопка получает знакомый пользователю отклик без зависимости на целый UI-kit.
Практические замечания
Есть несколько ограничений, которые лучше учитывать сразу:
- на тач-устройствах обычно стоит перейти с
onMouseDownнаonPointerDown; - если у кнопки сложный контент и много интерактивных дочерних элементов, проверь, что слой ряби не ломает фокус и клики;
- если тебе нужна единая система кнопок, ripple логичнее встроить в базовый
Button, а не размазывать по приложению вручную.
Возьми одну реальную кнопку из проекта и проверь две вещи: правильно ли считается точка клика при прокрутке страницы и не остается ли мусор в DOM после серии быстрых нажатий.