---
title: Dots
description: Twinkling dot field with density fade and live controls.
group: Backgrounds
order: 13
new: true
---

A canvas field of small dots that reseeds and twinkles. Anchor density to an edge, then tune size, gap, fade, and brightness.

```tsx
import { Dots } from "@/components/motion/dots"

export function Example() {
  return (
    <Dots
      className="h-[420px] w-full"
      direction="right"
      color="#3b82f6"
    />
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add dots
```

## Usage

```tsx
import { Dots } from "@/components/motion/dots"
```

```tsx
<Dots
  className="h-[420px] w-full"
  direction="right"
  color="#3b82f6"
/>
```

## API Reference

### Dots

| Prop | Type | Default |
| --- | --- | --- |
| dotSize | number | 3 |
| gap | number | 10 |
| color | string | #3b82f6 |
| backgroundColor | string | — |
| maxOpacity | number | 0.5 |
| flickerChance | number | 0.1 |
| twinkleSpeed | number | 1 |
| twinkleStrength | number | 0.5 |
| minBrightness | number | 0.3 |
| intensity | number | 1 |
| opacity | number | 1 |
| direction | none right left top bottom | none |
| fadeStart | number | 0.15 |
| fadeEnd | number | 1 |
| falloff | number | 1.25 |
| dpr | number | 1.5 |
| className | string | — |
| children | ReactNode | — |

Also accepts the other native `div` attributes. Dot size and gap are in pixels.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  type ComponentProps,
  type CSSProperties,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

export type DotsDirection = "right" | "left" | "top" | "bottom" | "none";

export type DotsProps = Omit<ComponentProps<"div">, "children" | "color"> & {
  /** Diameter of each dot in px. */
  dotSize?: number;
  /** Gap between dots in px. */
  gap?: number;
  /** Dot fill color. */
  color?: string;
  /** Optional solid fill behind the field. */
  backgroundColor?: string;
  /** Peak cell opacity before master opacity. */
  maxOpacity?: number;
  /** Chance per second a cell reseeds its brightness. */
  flickerChance?: number;
  /** Continuous twinkle rate in cycles per second. */
  twinkleSpeed?: number;
  /** How hard the twinkle swings brightness (0–1). */
  twinkleStrength?: number;
  /** Floor for a lit cell’s brightness (0–1). */
  minBrightness?: number;
  /** Master brightness multiplier. */
  intensity?: number;
  /** Master alpha (0–1). */
  opacity?: number;
  /** Edge the dense field anchors to. `none` keeps density even. */
  direction?: DotsDirection;
  /** Where cells start appearing along the fade axis (0–1). */
  fadeStart?: number;
  /** Where density reaches full along the fade axis (0–1). */
  fadeEnd?: number;
  /** Fade curve sharpness. 1 = linear. */
  falloff?: number;
  /** Cap for device pixel ratio. */
  dpr?: number;
  children?: ReactNode;
};

type Rgba = { r: number; g: number; b: number };

function parseColor(color: string): Rgba {
  if (typeof document === "undefined") return { r: 113, g: 113, b: 122 };
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 1;
  const ctx = canvas.getContext("2d");
  if (!ctx) return { r: 113, g: 113, b: 122 };
  ctx.fillStyle = "#000";
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, 1, 1);
  const [r = 113, g = 113, b = 122] = ctx.getImageData(0, 0, 1, 1).data;
  return { r, g, b };
}

function densityAt(
  col: number,
  row: number,
  cols: number,
  rows: number,
  direction: DotsDirection,
  fadeStart: number,
  fadeEnd: number,
  falloff: number,
) {
  if (direction === "none") return 1;

  let t = 0;
  if (direction === "right") t = cols <= 1 ? 1 : col / (cols - 1);
  else if (direction === "left") t = cols <= 1 ? 1 : 1 - col / (cols - 1);
  else if (direction === "bottom") t = rows <= 1 ? 1 : row / (rows - 1);
  else t = rows <= 1 ? 1 : 1 - row / (rows - 1);

  const start = Math.min(Math.max(fadeStart, 0), 1);
  const end = Math.max(Math.min(fadeEnd, 1), start + 0.001);
  if (t <= start) return 0;
  if (t >= end) return 1;
  const u = (t - start) / (end - start);
  return Math.pow(u, Math.max(falloff, 0.05));
}

function Dots({
  className,
  dotSize = 3,
  gap = 10,
  color = "#3b82f6",
  backgroundColor,
  maxOpacity = 0.5,
  flickerChance = 0.1,
  twinkleSpeed = 1,
  twinkleStrength = 0.5,
  minBrightness = 0.3,
  intensity = 1,
  opacity = 1,
  direction = "none",
  fadeStart = 0.15,
  fadeEnd = 1,
  falloff = 1.25,
  dpr: dprCap = 1.5,
  children,
  style,
  ...props
}: DotsProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const paramsRef = useRef({
    dotSize,
    gap,
    color,
    backgroundColor,
    maxOpacity,
    flickerChance,
    twinkleSpeed,
    twinkleStrength,
    minBrightness,
    intensity,
    opacity,
    direction,
    fadeStart,
    fadeEnd,
    falloff,
    dprCap,
  });

  paramsRef.current = {
    dotSize,
    gap,
    color,
    backgroundColor,
    maxOpacity,
    flickerChance,
    twinkleSpeed,
    twinkleStrength,
    minBrightness,
    intensity,
    opacity,
    direction,
    fadeStart,
    fadeEnd,
    falloff,
    dprCap,
  };

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let cols = 0;
    let rows = 0;
    let bases = new Float32Array(0);
    let phases = new Float32Array(0);
    let rgba: Rgba = parseColor(paramsRef.current.color);
    let colorKey = paramsRef.current.color;
    let layoutKey = "";
    let raf = 0;
    let last = performance.now();
    let running = true;
    let inView = true;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

    function rebuild() {
      const p = paramsRef.current;
      const cssW = Math.max(root!.clientWidth, 1);
      const cssH = Math.max(root!.clientHeight, 1);
      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      canvas!.width = Math.floor(cssW * dpr);
      canvas!.height = Math.floor(cssH * dpr);
      canvas!.style.width = `${cssW}px`;
      canvas!.style.height = `${cssH}px`;

      const cell = Math.max(p.dotSize, 1) + Math.max(p.gap, 0);
      cols = Math.max(Math.ceil(cssW / cell), 1);
      rows = Math.max(Math.ceil(cssH / cell), 1);
      bases = new Float32Array(cols * rows);
      phases = new Float32Array(cols * rows);
      const floor = Math.min(Math.max(p.minBrightness, 0), 1);
      for (let i = 0; i < bases.length; i++) {
        bases[i] = floor + Math.random() * (1 - floor);
        phases[i] = Math.random() * Math.PI * 2;
      }
      layoutKey = `${cssW}x${cssH}:${p.dotSize}:${p.gap}:${p.dprCap}:${floor}`;
      if (p.color !== colorKey) {
        rgba = parseColor(p.color);
        colorKey = p.color;
      }
    }

    function ensureLayout() {
      const p = paramsRef.current;
      const cssW = Math.max(root!.clientWidth, 1);
      const cssH = Math.max(root!.clientHeight, 1);
      const floor = Math.min(Math.max(p.minBrightness, 0), 1);
      const next = `${cssW}x${cssH}:${p.dotSize}:${p.gap}:${p.dprCap}:${floor}`;
      if (next !== layoutKey) rebuild();
    }

    function paint(now: number) {
      if (!running) return;
      ensureLayout();
      const p = paramsRef.current;
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;

      if (p.color !== colorKey) {
        rgba = parseColor(p.color);
        colorKey = p.color;
      }

      const dpr = Math.min(window.devicePixelRatio || 1, Math.max(p.dprCap, 1));
      const size = Math.max(p.dotSize, 1) * dpr;
      const spacing = Math.max(p.gap, 0) * dpr;
      const cell = size + spacing;
      const radius = size / 2;
      const floor = Math.min(Math.max(p.minBrightness, 0), 1);
      const peak = Math.min(Math.max(p.maxOpacity, 0), 1);
      const master = Math.min(Math.max(p.opacity, 0), 1);
      const strength = Math.min(Math.max(p.twinkleStrength, 0), 1);
      const speed = Math.max(p.twinkleSpeed, 0);
      const chance = Math.max(p.flickerChance, 0);
      const boost = Math.max(p.intensity, 0);

      if (inView && !reduced.matches && chance > 0) {
        for (let i = 0; i < bases.length; i++) {
          if (Math.random() < chance * dt) {
            bases[i] = floor + Math.random() * (1 - floor);
          }
        }
      }

      ctx!.setTransform(1, 0, 0, 1, 0, 0);
      ctx!.clearRect(0, 0, canvas!.width, canvas!.height);

      if (p.backgroundColor) {
        ctx!.fillStyle = p.backgroundColor;
        ctx!.fillRect(0, 0, canvas!.width, canvas!.height);
      }

      const t = now / 1000;
      for (let c = 0; c < cols; c++) {
        for (let r = 0; r < rows; r++) {
          const i = c * rows + r;
          const mask = densityAt(
            c,
            r,
            cols,
            rows,
            p.direction,
            p.fadeStart,
            p.fadeEnd,
            p.falloff,
          );
          if (mask <= 0.001) continue;

          let bright = bases[i] ?? floor;
          if (!reduced.matches && speed > 0 && strength > 0) {
            const wave = Math.sin(t * speed * Math.PI * 2 + (phases[i] ?? 0));
            bright = bright * (1 - strength * 0.5 + wave * strength * 0.5);
          }

          const alpha = Math.min(
            Math.max(bright * mask * peak * boost * master, 0),
            1,
          );
          if (alpha <= 0.001) continue;

          const cx = c * cell + radius;
          const cy = r * cell + radius;
          ctx!.beginPath();
          ctx!.arc(cx, cy, radius, 0, Math.PI * 2);
          ctx!.fillStyle = `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${alpha})`;
          ctx!.fill();
        }
      }

      raf = window.requestAnimationFrame(paint);
    }

    rebuild();
    last = performance.now();
    raf = window.requestAnimationFrame(paint);

    const resizeObserver = new ResizeObserver(() => rebuild());
    resizeObserver.observe(root);

    const intersectionObserver = new IntersectionObserver(
      ([entry]) => {
        inView = entry?.isIntersecting ?? true;
      },
      { threshold: 0 },
    );
    intersectionObserver.observe(canvas);

    return () => {
      running = false;
      window.cancelAnimationFrame(raf);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
    };
  }, []);

  return (
    <div
      ref={rootRef}
      data-slot="dots"
      className={cn("arctis-dots relative overflow-hidden", className)}
      style={style as CSSProperties}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="arctis-dots-canvas pointer-events-none absolute inset-0 size-full"
        aria-hidden="true"
      />
      {children ? (
        <div className="relative z-10">{children}</div>
      ) : null}
    </div>
  );
}

export { Dots };
```
