Searchlight
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<label class="cb-searchlight">
<input class="cb-searchlight__input" type="checkbox"/>
<svg class="cb-searchlight__art" viewBox="0 0 200 200" aria-hidden="true">
<defs>
<clipPath id="cb-searchlight-clip">
<circle cx="100" cy="100" r="100"></circle>
</clipPath>
<linearGradient id="cb-searchlight-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--sky-a)"></stop>
<stop offset="1" stop-color="var(--sky-b)"></stop>
</linearGradient>
<linearGradient id="cb-searchlight-ray" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="-122">
<stop offset="0" stop-color="var(--lit)" stop-opacity="0.95"></stop>
<stop offset="1" stop-color="var(--lit)" stop-opacity="0.2"></stop>
</linearGradient>
</defs>
<g clip-path="url(#cb-searchlight-clip)">
<rect width="200" height="200" fill="url(#cb-searchlight-sky)"></rect>
<circle class="cb-searchlight__star" cx="22" cy="70" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="40" cy="40" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="66" cy="22" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="92" cy="34" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="150" cy="22" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="178" cy="62" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="170" cy="96" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="30" cy="104" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="104" cy="96" r="0.9"></circle>
<circle class="cb-searchlight__star" cx="78" cy="74" r="0.9"></circle>
<g class="cb-searchlight__drift">
<ellipse class="cb-searchlight__cloud" cx="68" cy="44" rx="42" ry="14"></ellipse>
<ellipse class="cb-searchlight__cloud" cx="124" cy="58" rx="60" ry="24"></ellipse>
<ellipse class="cb-searchlight__cloud" cx="170" cy="40" rx="36" ry="13"></ellipse>
</g>
<g class="cb-searchlight__lamp">
<path class="cb-searchlight__beam" d="M-5 0L-22 -122H22L5 0Z" fill="url(#cb-searchlight-ray)"></path>
<rect class="cb-searchlight__drum" x="-7.5" y="-5" width="15" height="11" rx="2"></rect>
<rect class="cb-searchlight__glass" x="-6" y="-7" width="12" height="2.6" rx="1"></rect>
</g>
<g class="cb-searchlight__spot">
<ellipse class="cb-searchlight__glass" cx="124" cy="58" rx="36" ry="24"></ellipse>
<path class="cb-searchlight__tick" d="M108 58L119 70L141 45"></path>
</g>
<path class="cb-searchlight__mast" d="M56 162H60V172H56Z"></path>
<path class="cb-searchlight__city" d="M0 200V170H22V150H34V164H52V172H74V154H84V146H96V154H104V176H124V160H138V132H148V124H156V132H166V162H182V172H200V200Z"></path>
<path class="cb-searchlight__glass" d="M142 140h3v4h-3zM150 140h3v4h-3zM142 150h3v4h-3zM158 142h0zM88 158h3v4h-3zM26 158h3v4h-3zM110 182h3v4h-3zM170 170h3v4h-3z" opacity="0.8"></path>
</g>
</svg>
Send the signal
</label>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(58px, 158px) rotate(-42deg); transition: transform 0.6s cubic-bezier(0.4, 0, 0.3, 1); }
.cb-searchlight:hover .cb-searchlight__lamp { transform: translate(58px, 158px) rotate(-26deg); }
.cb-searchlight__input:checked + .cb-searchlight__art .cb-searchlight__lamp { transform: translate(58px, 158px) rotate(33.42deg); 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; }
}Goal: Create a "Searchlight" checkbox.
Visual behavior: 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.
Technique:
- The lamp is one SVG group turned about its base. Hover lifts it a little, and ticking turns it to a fixed aim with a small overshoot while the beam brightens.
- The patch of light and the tick inside it pop up as the beam arrives, on a delay matched to the turn. The tick is a sky-coloured stroke on the bright ellipse, so it reads as a shadow cut out of the light. The clouds drift on a long alternating keyframe. Pure CSS.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Searchlight" checkbox.
Visual behavior: 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.
Technique:
- The lamp is one SVG group turned about its base. Hover lifts it a little, and ticking turns it to a fixed aim with a small overshoot while the beam brightens.
- The patch of light and the tick inside it pop up as the beam arrives, on a delay matched to the turn. The tick is a sky-coloured stroke on the bright ellipse, so it reads as a shadow cut out of the light. The clouds drift on a long alternating keyframe. Pure CSS.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.