Ripple effect нужен не ради “красивой анимации”, а ради понятного отклика интерфейса. Пользователь нажал кнопку и сразу получил визуальный сигнал, что событие действительно обработалось.

Подключать большую UI-библиотеку только ради ряби обычно бессмысленно. Эффект достаточно простой, и его можно собрать на чистом React с парой styled-components. Важно только не оставлять placeholder-код и правильно считать координаты клика относительно контейнера.

Обновлено 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 после серии быстрых нажатий.