Scanline
Effects & Borders

Scanline

A subtle CRT scanline overlay with an optional vignette and slow refresh sweep — enterprise-tasteful monitor texture.

Install

npx shadcn@latest add @paragon/scanline

scanline.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface ScanlineProps extends React.ComponentProps<"div"> {
  /** Overall overlay opacity, 0–1. Keep low — this is a texture, not a filter. */
  intensity?: number;
  /** Distance between scanlines in px. */
  gap?: number;
  /** Corner radius in px. Match the parent's radius. */
  borderRadius?: number;
  /** Add a soft CRT vignette darkening the edges. */
  vignette?: boolean;
  /** A faint bright bar that sweeps top→bottom, like a refresh scan. */
  sweep?: boolean;
  /** Seconds for one sweep pass (sweep only). */
  duration?: number;
}

const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1);

/**
 * A subtle CRT scanline overlay with an optional vignette and refresh sweep —
 * enterprise-tasteful terminal/monitor texture, not a retro gimmick.
 *
 * Pure CSS: a repeating-linear-gradient lays down the horizontal lines
 * (token-derived so it tints correctly in both themes), an optional radial
 * mask darkens the corners as a vignette, and an optional slim highlight bar
 * translates top→bottom for the refresh scan. Only `transform` animates. The
 * sweep pauses offscreen via IntersectionObserver and freezes under
 * prefers-reduced-motion (the static lines + vignette stay). Absolutely
 * positioned — parent needs position: relative (and usually overflow-hidden).
 */
export function Scanline({
  intensity = 0.5,
  gap = 3,
  borderRadius = 12,
  vignette = true,
  sweep = false,
  duration = 6,
  className,
  style,
  ref: forwardedRef,
  ...props
}: ScanlineProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const localRef = React.useRef<HTMLDivElement | null>(null);
  const [inView, setInView] = React.useState(true);

  React.useEffect(() => {
    if (!sweep) return;
    const node = localRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry?.isIntersecting ?? true);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [sweep]);

  const level = clamp01(intensity);
  const gapPx = Math.max(2, gap);
  // Lines are drawn from the foreground token so they read in both themes; the
  // ceiling is deliberately low so this stays a whisper.
  const lineAlpha = (0.16 * level).toFixed(3);

  return (
    <>
      {sweep && (
        <style href={`paragon-scanline-${id}`} precedence="paragon">{`
          @keyframes scanline-sweep-${id} {
            0% { transform: translateY(-120%); }
            100% { transform: translateY(120%); }
          }
          [data-scanline="${id}"][data-paused] > span { animation-play-state: paused; }
          @media (prefers-reduced-motion: reduce) {
            [data-scanline="${id}"] > span { animation: none !important; opacity: 0 !important; }
          }
        `}</style>
      )}
      <div
        aria-hidden
        data-scanline={id}
        data-paused={!inView || undefined}
        ref={(node) => {
          localRef.current = node;
          if (typeof forwardedRef === "function") forwardedRef(node);
          else if (forwardedRef) forwardedRef.current = node;
        }}
        className={cn(
          "pointer-events-none absolute inset-0 overflow-hidden text-foreground",
          className,
        )}
        style={{
          borderRadius,
          backgroundImage: `repeating-linear-gradient(0deg, color-mix(in oklab, currentColor ${(+lineAlpha * 100).toFixed(1)}%, transparent) 0px, color-mix(in oklab, currentColor ${(+lineAlpha * 100).toFixed(1)}%, transparent) 1px, transparent 1px, transparent ${gapPx}px)`,
          ...(vignette && {
            boxShadow: `inset 0 0 ${Math.round(40 + 60 * level)}px rgba(0,0,0,${(0.28 * level).toFixed(3)})`,
          }),
          ...style,
        }}
        {...props}
      >
        {sweep && (
          <span
            style={{
              position: "absolute",
              insetInline: 0,
              top: 0,
              height: "22%",
              background:
                "linear-gradient(to bottom, transparent, color-mix(in oklab, currentColor 8%, transparent) 55%, transparent)",
              opacity: 0.5 + 0.5 * level,
              willChange: "transform",
              animation: `scanline-sweep-${id} ${duration}s linear infinite`,
            }}
          />
        )}
      </div>
    </>
  );
}