Skip to content
Plinth

Backgrounds

Halftone Ripple

A tiled dot matrix with rings travelling out through it, moved by animating mask-size rather than redrawing a single dot.

#background#halftone#mask#ripple#animation

Install

npx shadcn@latest add https://plinthui.com/r/halftone-ripple.json
Open in v0
// No hooks or handlers, so this stays a server component — it is pure CSS.
// Masked repeating gradients and blend modes are outside what Tailwind can
// express, so the CSS ships as a string.
const css = `
.halftone {
  position: relative;
  min-height: 300px;
  height: 100%;
  overflow: hidden;
  background: #070310;
  isolation: isolate;
}

/* One element paints the entire dot matrix: a radial-gradient tiled by
   background-size. N dots would be N elements and N layout boxes; this is one
   paint whatever the density. */
.halftone__dots {
  position: absolute;
  inset: -10%;
  background-image: radial-gradient(circle at center, #fff 0 3.4px, transparent 3.9px);
  background-size: 13px 13px;
}

/* The ripple is a MASK, not a change to the dots. Rings of opaque and clear
   sweep outward, and each dot simply shows where a ring is currently over it,
   so the matrix itself never re-renders. Scaling mask-size is what moves the
   rings: the whole repeating pattern grows from the origin, which reads as a
   wave travelling out. Gradient colour stops cannot be animated without
   @property, but mask-size can, so the motion is free. */
.halftone__dots--a {
  -webkit-mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
          mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
  animation: halftone-out 6.5s linear infinite;
}

.halftone__dots--b {
  -webkit-mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
          mask-image: repeating-radial-gradient(circle at 46% 52%, #000 0 15%, transparent 15% 26%);
  /* Same wave, half a period behind: the gaps of one land on the crests of the
     other, so the field never fully empties between rings. */
  animation: halftone-out 6.5s linear infinite -3.25s;
  opacity: 0.72;
}

@keyframes halftone-out {
  from { -webkit-mask-size: 30% 30%; mask-size: 30% 30%; opacity: 0; }
  18%  { opacity: 1; }
  to   { -webkit-mask-size: 210% 210%; mask-size: 210% 210%; opacity: 0; }
}

/* multiply over a white dot field: gradient x white = gradient, and the gaps
   stay black. One gradient colours every dot, so density changes never shift
   the palette. */
.halftone__tint {
  position: absolute;
  inset: 0;
  background: linear-gradient(120deg, #22d3ee 0%, #6366f1 45%, #ec4899 100%);
  mix-blend-mode: multiply;
  pointer-events: none;
}

.halftone__core {
  position: absolute;
  inset: 0;
  background: radial-gradient(28% 28% at 46% 52%, rgba(255, 255, 255, 0.16), transparent 55%);
  mix-blend-mode: screen;
  pointer-events: none;
}

.halftone__vignette {
  position: absolute;
  inset: 0;
  background: radial-gradient(115% 85% at 46% 52%, transparent 66%, rgba(3, 1, 8, 0.5) 100%);
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .halftone__dots { animation: none; }
  .halftone__dots--a { -webkit-mask-size: 120% 120%; mask-size: 120% 120%; opacity: 1; }
  .halftone__dots--b { display: none; }
}
`;

export function HalftoneRipple() {
  return (
    <>
      <style>{css}</style>
      <div className="halftone">
        <div className="halftone__dots halftone__dots--a" />
        <div className="halftone__dots halftone__dots--b" />
        <div className="halftone__tint" />
        <div className="halftone__core" />
        <div className="halftone__vignette" />
      </div>
    </>
  );
}

More in Backgrounds

Similar tags