Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Aim reticle

New

A game sight in two kinds: three ticks that spread and tighten with a distance readout, and a glowing cross that locks on.

aim-reticle/aim-reticle.tsx
Live · interactive

Move the pointer or press the arrow keys to turn the view under the sight; the middle holds a target. Hold the pointer, Space or Enter to draw, and release to shoot. Distance 60.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A game or playful view where the player aims at a point and the sight has to say how steady the aim is and how far the shot will go.
  • A charged action, held to draw and released to fire: the spread ticks close in as the draw builds and flash at full draw.
  • Picking out one target to track: the lock reticle zooms while held and, while held, snaps its ticks in once the target stays under the cross.
  • A sight laid over your own game view: the stage is see-through, and the sight carries its own dark edge, so it reads over bright and dark frames alike.

When not to use

  • Picking a point on a map or an image in a working tool. Use a plain crosshair cursor or a map pin.
  • Showing how far along a task is. Use a progress bar or a progress ring.
  • Choosing one of a few options by holding. Use a radial menu or a quick selector.
  • A countdown before an action happens. Use a timer ring or an effect timer.
  • Pointing the reader at something off screen. Use a waypoint marker at the screen’s edge.

You can add this component with the shadcn CLI or by hand:

Into components/aim-reticle of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/aim-reticle.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

bow-hud.tsx
import { AimReticle } from './aim-reticle';export function BowHud({ aiming, drawn, meters }: { aiming: boolean; drawn: number; meters: number }) {  if (!aiming) return null;  return (    <AimReticle      variant="spread"      charge={drawn}      distance={meters}      controls={false}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

The sight a game draws in the middle of the screen, on the line of fire, shown on its own. The spread reticle opens while the view turns, closes as it holds still, draws in further while a shot is held and kicks out when it flies, with the distance to whatever sits under the dot laid along one tick. The lock reticle zooms while held, and snaps its four ticks in round a target kept under the cross. Each shape carries its own dark edge, so the sight reads on light and dark alike.

  • The sight stays in the middle of its stage, as a HUD keeps it in the middle of the screen; the pointer, a finger or the arrow keys turn the view under it. A target sits where the view started, so turning back to the middle puts the dot on it; `aimOnTarget` says so from your game instead.
  • One requestAnimationFrame loop owns the motion, and only while the stage is on screen and its tab visible: the look eases after the input, its speed heats the spread (fast to open, about 260 ms to cool), and it sleeps once everything is still.
  • Each kind of tick rides a damped spring, a little under critical so it overshoots and settles like a physical sight. The loop writes their radii as two custom properties, --r and --lr, and every tick places itself with rotate() and translateY() from them.
  • Spread: three 6 × 26 px ticks at 0°, 120° and 240°, 48 px out at rest, around a 6 px dot. Turning opens them by up to 32 px; a hold draws them in by 20 px over 0.9 s, with a flash and a warm flare at full draw; the release kicks the spring outward, and a ring lands on the dot later the further the shot flies, with a four-mark hit sign on a target.
  • The readout runs outward along the lower-right tick on its upper side, so it travels with the spread. It reads the target’s distance when the dot is on it, and otherwise the ground’s, further as the look rises; the number rolls there rather than jumping.
  • Lock: a 60 px cross 4 px thick and four 4 × 22 px ticks 100 px out, cyan with a glow behind each bar, so where the bars cross the light doubles. Holding zooms and draws the ticks to 86 px; only then, a target under the cross for 200 ms (within 26 px) locks: the ticks snap to 52 px, the cross pops and the glow pulses twice; it lets go past 40 px.
  • Every shape is a CSS box drawn at its size times `scale`, with no bitmap, blur filter or scaling transform at rest, so it stays sharp at any pixel ratio. White and cyan keep a crisp dark outline (a box-shadow spread, or a stroke painted under the figures) with a soft shade or glow behind it, so the sight reads on a light page as on a dark one.
PropTypeDefaultDescription
variant'spread' | 'lock'—Which reticle shows; with it the reticle is controlled, so pair it with onVariantChange. A change lowers one sight and raises the other.
defaultVariant'spread' | 'lock''spread'The reticle shown first, when variant is not set.
onVariantChange(variant: 'spread' | 'lock') => void—Called when the reader picks the other reticle.
distancenumber—Pins the spread readout to this number; otherwise it reads the look. Turns the demo off.
spreadnumber—Pins how far turning has opened the spread ticks, 0 to 1 (up to 32 px). Turns the demo off.
chargenumber—Pins the draw, 0 to 1: the spread ticks close in by up to 20 px, with a flash at 1. Turns the demo off.
zoomnumber—Pins the lock reticle’s zoom, 0 to 1. Turns the demo off.
lockedboolean—Pins the lock: the ticks snap in round the cross. Turns the demo off.
aimOnTargetboolean—Says whether a target sits under the sight, in place of the look’s own target: the readout reads the target, a shot hits and the lock reticle can lock on. Turns the demo off.
controlsbooleantrueShows the two chips that swap the reticle, and the hold hint, under the sight.
scalenumber1Draws the reticle this many times its own size.
onFire(shot: { charge: number; distance: number; hit: boolean }) => void—Called on each shot of the spread reticle, with how far it was drawn.
onLockChange(locked: boolean) => void—Called when the lock reticle locks on or loses the target.
autoplaybooleantrueWhile on screen and untouched, a short run turns, draws and fires the spread reticle, then zooms and locks on with the other. Any real input hands it over.
colorScheme'light' | 'dark' | 'system'—Light or dark colours (the edge’s weight and the chips), or the reader’s; without it, the page’s color-scheme.
classNamestring—Added to the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

aim-reticle.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './aim-reticle.module.css';/* * Aim reticle: the sight a game draws on the player's line of fire, in two kinds, shown on its own * in the middle of its aiming area, as a HUD draws it in the middle of the screen. * * - Spread: three white ticks around a dot, with the distance to whatever sits under the dot laid *   along the lower-right tick. The ticks fly apart while the view turns and close back in as it *   holds still; holding draws the shot and pulls them in further, to a flash at full draw, and *   releasing fires: the ticks kick out, and a ring (with a hit mark on a target) lands on the dot
aim-reticle.module.css
/* Aim reticle (aim-reticle.tsx): the sight alone, in the middle of a see-through aiming area, with   the reticle chips and the hold hint in a row under it. The reticle is drawn in the drawing's own   px times --s (the scale prop), from its middle; nothing is scaled by a transform at rest, so   every edge is drawn at the screen's own resolution.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). The sights keep their white and cyan on both, as   a game's HUD does, each with its own dark edge (a crisp outline, then a soft shade behind it) so   they read on a light stage as on a dark one; only the edge's weight and the chips change.   Memory. The stage is one layout boundary, sized from outside. The tick radii are two custom   properties (--r, --lr) on the reticle, so a frame restyles only its few small boxes. */.root {  /* The spread sight, its edge and the soft shade behind the edge. */  --tick: #fffffa;  --edge: rgb(14 18 24 / 0.78);  --shade: rgb(14 18 24 / 0.22);  /* Full draw: the warm flare round the ticks. */  --flare: rgb(255 184 64 / 0.85);  /* The lock sight, its edge and its glow. */  --cyan: #a2e8fa;  --cyan-edge: rgb(3 38 56 / 0.82);
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The aiming area is one focusable group: the arrow keys turn the view (Shift for bigger steps), Space or Enter held draws or zooms, released shoots, and Escape lets go without shooting. Its description says how, and the current distance or lock.
  • A status region announces each shot (its distance, a full draw, a hit) and each lock gained or lost.
  • The reticle buttons are real buttons with aria-pressed, and every control has a visible focus ring.
  • The sights and the shot effects are aria-hidden: their meaning is in the text above.
  • With reduced motion there is no demo, no overshoot, pulse or flash: the sights move straight to each state.
  • The loop and the demo pause when the stage is off screen or its tab hidden.