A subtle CRT scanline overlay with an optional vignette and slow refresh sweep — enterprise-tasteful monitor texture.
npx shadcn@latest add @paragon/scanline"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>
</>
);
}