Skip to content
← Components

Ripple Field

Ripple Field adds a distinctive visual surface to your composition.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/ripple-field.json

Use it

Scroll horizontally for long lines
"use client";
import { RippleField } from "@/components/jez-ui/ui/ripple-field";

export default function Example() {
  return (
    <>
      <RippleField />
    </>
  );
}

Props & types

Scroll horizontally for long lines
Omit<
  React.ComponentProps<"button">,
  keyof {
    className?: string;
    children?: React.ReactNode;
    label?: string;
  }
> & {
  className?: string;
  children?: React.ReactNode;
  label?: string;
}

Source

View 2 source files

registry/ui/ripple-field.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function RippleField({
  className,
  children = "Click anywhere. Make a little wave.",
  label = "Create a ripple",
  ...rootProps
}: Omit<
  React.ComponentProps<"button">,
  keyof {
    className?: string;
    children?: React.ReactNode;
    label?: string;
  }
> & {
  className?: string;
  children?: React.ReactNode;
  label?: string;
}) {
  const [ripples, setRipples] = React.useState<
    { id: number; x: number; y: number }[]
  >([]);
  return (
    <button
      {...rootProps}
      type="button"
      aria-label={label}
      className={cn(
        "relative block h-64 w-full overflow-hidden rounded-xl bg-muted",
        className,
      )}
      onClick={(e) => {
        rootProps.onClick?.(e);
        if (e.defaultPrevented) return;

        const r = e.currentTarget.getBoundingClientRect();
        setRipples((v) => [
          ...v.slice(-8),
          {
            id: performance.now(),
            x: e.detail ? e.clientX - r.left : r.width / 2,
            y: e.detail ? e.clientY - r.top : r.height / 2,
          },
        ]);
      }}
    >
      <span className="relative z-10 text-sm">{children}</span>
      {ripples.map((r) => (
        <span
          key={r.id}
          onAnimationEnd={() =>
            setRipples((v) => v.filter((x) => x.id !== r.id))
          }
          aria-hidden="true"
          className="pointer-events-none absolute size-24 rounded-full border border-primary"
          style={{
            left: r.x - 48,
            top: r.y - 48,
            animation: "jez-ripple 1.2s ease-out forwards",
          }}
        />
      ))}
    </button>
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

Dependencies

clsx@2.1.1 · tailwind-merge@3.5.0

Accessibility & behaviour

Respect reduced-motion preferences. Decorative effects must not carry essential information. WebGL scenes include a static fallback and only render while visible.

Read the accessibility and performance guide →