Searchlight

No dependencies
Code format
React + Tailwind
import { useId } from 'react';

const LAMP = { x: 58, y: 158 };
const SPOT = { x: 124, y: 58 };
// The angle that points the beam from the lamp at the cloud.
const AIM = (Math.atan2(SPOT.x - LAMP.x, LAMP.y - SPOT.y) * 180) / Math.PI;
const STARS = [
  [22, 70],
  [40, 40],
  [66, 22],
  [92, 34],
  [150, 22],
  [178, 62],
  [170, 96],
  [30, 104],
  [104, 96],
  [78, 74],
];

const css = `
  .cb-searchlight { --sky-a: light-dark(#373a40, #121417); --sky-b: light-dark(#50535b, #232529); --cloud: light-dark(#5f626a, #393c41); --city: light-dark(#373a40, #121417); --lit: light-dark(#fbfcfd, #f1f2f3); flex: none; display: flex; flex-direction: column; align-items: center; gap: 6px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; font-weight: 500; cursor: pointer; -webkit-user-select: none; user-select: none; }
  .cb-searchlight__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .cb-searchlight__art { display: block; width: 200px; height: 200px; border-radius: 50%; box-shadow: 0 0 0 2px light-dark(#6d7179, #7d8087); }
  .cb-searchlight__input:focus-visible + .cb-searchlight__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .cb-searchlight__star { fill: var(--lit); opacity: 0.55; }
  .cb-searchlight__drift { animation: cb-searchlight-drift 16s ease-in-out infinite alternate; }
  @keyframes cb-searchlight-drift {
    from { transform: translateX(-7px); }
    to { transform: translateX(7px); }
  }
  .cb-searchlight__cloud { fill: var(--cloud); opacity: 0.62; }
  .cb-searchlight__lamp { transform: translate(${LAMP.x}px, ${LAMP.y}px) rotate(-42deg); transition: transform 0.6s cubic-bezier(0.4, 0, 0.3, 1); }
  .cb-searchlight:hover .cb-searchlight__lamp { transform: translate(${LAMP.x}px, ${LAMP.y}px) rotate(-26deg); }
  .cb-searchlight__input:checked + .cb-searchlight__art .cb-searchlight__lamp { transform: translate(${LAMP.x}px, ${LAMP.y}px) rotate(${AIM.toFixed(2)}deg); transition: transform 0.85s cubic-bezier(0.3, 1.25, 0.5, 1); }
  .cb-searchlight__beam { opacity: 0.32; transition: opacity 0.4s; }
  .cb-searchlight__input:checked + .cb-searchlight__art .cb-searchlight__beam { opacity: 0.92; }
  .cb-searchlight__drum { fill: var(--cloud); }
  .cb-searchlight__city { fill: var(--city); }
  .cb-searchlight__glass { fill: var(--lit); }
  .cb-searchlight__mast { fill: var(--cloud); }
  .cb-searchlight__tick { fill: none; stroke: var(--sky-a); stroke-width: 8.5; stroke-linecap: round; stroke-linejoin: round; }
  .cb-searchlight__spot { transform-box: fill-box; transform-origin: center; opacity: 0; transform: scale(0.6); transition: opacity 0.16s, transform 0.16s; }
  .cb-searchlight__input:checked + .cb-searchlight__art .cb-searchlight__spot { opacity: 1; transform: none; transition: opacity 0.18s 0.58s, transform 0.42s cubic-bezier(0.3, 1.6, 0.5, 1) 0.58s; }
  @media (prefers-reduced-motion: reduce) {
    .cb-searchlight *, .cb-searchlight__input:checked + .cb-searchlight__art * { animation: none !important; transition: none !important; }
  }
`;

export function SearchlightCheckbox() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
  return (
    <>
      <style>
        {css}
      </style>
      <label className="cb-searchlight">
        <input className="cb-searchlight__input" type="checkbox" />
        <svg className="cb-searchlight__art" viewBox="0 0 200 200" aria-hidden="true">
          <defs>
            <clipPath id={`${uid}clip`}>
              <circle cx="100" cy="100" r="100" />
            </clipPath>
            <linearGradient id={`${uid}sky`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--sky-a)" />
              <stop offset="1" stopColor="var(--sky-b)" />
            </linearGradient>
            <linearGradient id={`${uid}ray`} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="-122">
              <stop offset="0" stopColor="var(--lit)" stopOpacity="0.95" />
              <stop offset="1" stopColor="var(--lit)" stopOpacity="0.2" />
            </linearGradient>
          </defs>
          <g clipPath={`url(#${uid}clip)`}>
            <rect width="200" height="200" fill={`url(#${uid}sky)`} />
            {STARS.map(([x, y]) => (
              <circle key={`${x}${y}`} className="cb-searchlight__star" cx={x} cy={y} r="0.9" />
            ))}
            <g className="cb-searchlight__drift">
              <ellipse className="cb-searchlight__cloud" cx="68" cy="44" rx="42" ry="14" />
              <ellipse className="cb-searchlight__cloud" cx="124" cy="58" rx="60" ry="24" />
              <ellipse className="cb-searchlight__cloud" cx="170" cy="40" rx="36" ry="13" />
            </g>
            <g className="cb-searchlight__lamp">
              <path className="cb-searchlight__beam" d="M-5 0L-22 -122H22L5 0Z" fill={`url(#${uid}ray)`} />
              <rect className="cb-searchlight__drum" x="-7.5" y="-5" width="15" height="11" rx="2" />
              <rect className="cb-searchlight__glass" x="-6" y="-7" width="12" height="2.6" rx="1" />
            </g>
            <g className="cb-searchlight__spot">
              <ellipse className="cb-searchlight__glass" cx={SPOT.x} cy={SPOT.y} rx="36" ry="24" />
              <path className="cb-searchlight__tick" d="M108 58L119 70L141 45" />
            </g>
            <path className="cb-searchlight__mast" d="M56 162H60V172H56Z" />
            <path className="cb-searchlight__city" d="M0 200V170H22V150H34V164H52V172H74V154H84V146H96V154H104V176H124V160H138V132H148V124H156V132H166V162H182V172H200V200Z" />
            <path className="cb-searchlight__glass" d="M142 140h3v4h-3zM150 140h3v4h-3zM142 150h3v4h-3zM158 142h0zM88 158h3v4h-3zM26 158h3v4h-3zM110 182h3v4h-3zM170 170h3v4h-3z" opacity="0.8" />
          </g>
        </svg>
        Send the signal
      </label>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/checkbox-searchlight
Source on GitHub

About this component

A city at night through a round window. Tick it and a searchlight on the roof swings up and throws a tick onto the clouds.

Dependencies
None
Difficulty
Intermediate
Triggers
click, hover, state change, spotlight, ambient
Techniques
css transition, keyframes, svg, gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native checkbox inside a <label>, so "Send the signal" is its name. The scene is aria-hidden and keyboard focus outlines it. Under prefers-reduced-motion the clouds hold still and the beam moves at once.

More checkboxes

See all
Skate
Spray
Steam
Strap