Scope view
NewA game tablet’s spyglass: a round lens over a painted vector landscape, a reticle, smooth zoom, coloured pins placed in the world and a corner minimap.
scope-view/scope-view.tsxScope
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 full-screen tool the player raises to look far across the world, pick out a landmark and come back to the game.
- Marking places to visit later: a few coloured pins dropped under the reticle that stay on their spot and show on the minimap.
- A view the player steers with a stick or a drag and zooms smoothly, where the art has to stay sharp at every zoom.
- A screen played with a controller as much as with a mouse: arrows, Q and E, A to act and B to leave, with button hints at the foot.
- A device moment in a game’s fiction (spyglass, camera, binoculars), framed by the tablet it is shown on.
When not to use
- The whole map with every region and marker at once. Use a world map, which pans a flat chart.
- Showing where things are around the player while they play. Use a minimap or a radar in the HUD corner.
- Zooming into a photo or a product image on a web page. Use an image zoom or a lightbox.
- Aiming a weapon or a throw during play. Use an aim reticle over the game view; this one pauses play to look.
- A list of saved places to pick from. Use a list or a menu; pins here are placed by looking, not by name.
You can add this component with the shadcn CLI or by hand:
Into components/scope-view of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/scope-view.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
import { ScopeView, type ScopePin } from './scope-view';export function Spyglass({ onClose }: { onClose: () => void }) { return ( <ScopeView autoplay={false} defaultLook={{ x: 1442, y: 660 }} defaultZoom={2} defaultPins={[]} onPinsChange={(pins: ScopePin[]) => console.log(pins.length, 'pins')} onQuit={onClose} /> );}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 screen a player raises to look far across the world and mark what they find. A round lens, clipped by the tablet’s bars, frames a painted valley at sunset: a smoking peak, table mesas, a river, a pool and a watchtower, with scanlines, a vignette and a refracting rim. Drag or use the arrows to look around, scroll or use the gauge to zoom up to 8× (the art is vector, so it stays sharp), and press A to place a pin of the selected colour under the reticle. Pins stay on their spot as the view moves, show on the minimap at their bearing, and come back when you aim at them and press A again. Minus changes the colour, B lowers the scope like an iris.
- The tablet is drawn in SVG in its own units, 1024 × 576 (480 × 600 when the stage is under 640 px, chosen with a ResizeObserver): a navy surface with faint range rings, two black bars with stepped bronze bands, four stepped bronze brackets with a cyan gem in each boss, and the blue light the bars throw on the screen. Every HTML part is placed in the same units times --u, 1 px at full size, so the whole screen scales as one piece.
- The world is 2400 × 1000 units of vector art, generated once at load from a seeded random sequence (midpoint displacement for the ridges, scattered crowns for the woods), so server and browser draw the same picture. The lens shows it through one group transform, translate then scale: zooming rescales vector paths instead of enlarging pixels, so the art is sharp at 8×.
- Over the world, in screen space: a copy of it enlarged 4.5 % and masked to the outer ring of the lens (the edge distortion), vertical scanlines that shimmer slightly, a teal tint, a vignette and a light rim. All of it sits in a circle clip; the lens element opens and shuts with a clip-path circle transition, like an iris.
- The view (centre and zoom) eases to a target in a requestAnimationFrame loop that runs only while it moves: input sets the target with a snappy rate, the demo with a slow one, and zoom eases in log space so each doubling takes the same time. While zooming, the reticle’s arcs squeeze in and spring back.
- Pins are stored in world units. Each is drawn at (world − centre) × scale, so it stays on its spot at any zoom; a new one drops in with a squash and a ripple leaves the reticle, a removed one lifts away. Aiming within a few units of a pin turns the reticle cyan and the A hint into Remove Pin.
- The minimap draws a view cone at the bearing of the view’s centre (the world spans 150° of compass) that narrows as the zoom grows, and each pin as a dot at its bearing and a distance read from its height above the horizon.
- A demo raises the scope, swings to the tower, zooms, pins it, swings to the peak, changes colour, pins it, takes the first pin back and lowers the scope, while it is on screen and untouched; any input hands it to the reader.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'Scope' | The screen’s title, in the top bar. |
colors | ScopePinColor[] | Blue, Red, Yellow, Green, Pink | The pin colours of the palette, in order, as { id, label, color }. Each stamp gets its own mark, so colour is never the only cue. |
defaultPins | ScopePin[] | a blue and a red pin | The pins already placed, as { id, color, x, y } in world units (0–2400 across, 0–1000 down). |
pinLimit | number | 1 | How many pins of each colour can stand at once; a used-up colour dims. |
defaultLook | { x: number; y: number } | { x: 1200, y: 470 } | Where the reticle points at first, in world units. Home returns there. |
defaultZoom | number | 1 | The zoom it opens at, from 1 to maxZoom. |
maxZoom | number | 8 | The strongest zoom, the top of the gauge. |
defaultOpen | boolean | true | Whether the lens is raised at first. |
showMinimap | boolean | true | Shows the corner minimap with the view cone and the pins. |
onPinsChange | (pins: ScopePin[]) => void | — | Called with every pin after one is placed or removed. |
onZoomChange | (zoom: number) => void | — | Called when the zoom target changes. |
onQuit | () => void | — | Called when the lens is lowered (B, Escape or the Quit hint). |
onRaise | () => void | — | Called when the lens is raised again (A or the Raise Scope hint). |
autoplay | boolean | true | While on screen and untouched, a short look-around: aim, zoom, pin, change colour, take a pin back, lower and raise. |
colorScheme | 'light' | 'dark' | 'system' | — | Forces light or dark for the screen’s edge and shadow on the page; by default it follows the page. The screen itself stays dark. |
className | string | — | Added to the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { Backdrop, Bezel, FaceGlyph, LAYOUTS, MiniGround, PinMarker, PinStamp,// Copyright (c) 2026 Bruno Delorence. MIT License.import { memo } from 'react';/* * The painted world seen through the scope: one SVG group in world units, 2400 wide and 1000 * tall (x to the right, y down; the horizon sits near y 350). It is a sunset over a plain: a * smoking peak with a glowing crater on the left of centre, table mesas on the right, a river * that catches the light, a pool, a lattice watchtower with a beacon, falls in the western * cliffs, and woods scattered over the grass. * * Ridges and woods are generated once, at load, from a seeded random sequence (midpoint * displacement for ridges), so the server and the browser draw the same thing and nothing is * computed again while the view moves. Everything is vector: the scope zooms by scaling this * group, so it stays sharp at any zoom. */export const WORLD = { width: 2400, height: 1000, horizon: 350 };/** Named places, for what the reticle is on: the nearest one within its radius. */export const LANDMARKS = [ { name: 'the ember peak', x: 1002, y: 250, r: 170 }, { name: 'the watchtower', x: 1442, y: 668, r: 70 }, { name: 'the river bend', x: 1610, y: 560, r: 110 },// Copyright (c) 2026 Bruno Delorence. MIT License.import { memo } from 'react';/* * The tablet around the scope, drawn in SVG in the screen's own units (1024 × 576 wide, 480 × * 600 narrow): the navy surface with its faint range rings, the two black bars with their bronze * bands, the four stepped bronze corner brackets with a cyan gem in each boss, and the blue light * the bars and brackets throw on the screen. Also the small glyphs: the controller faces of the * hints, the pin stamps of the palette, the pin markers in the view, and the minimap's ground. */export type Layout = { name: 'wide' | 'narrow'; /** The screen, in its own units. */ w: number; h: number; /** Height of the top and bottom bars. */ bar: number; lens: { x: number; y: number; r: number }; mini: { x: number; y: number; r: number }; palette: { y: number }; gauge: { x: number; y0: number; y1: number }; hints: { right: number };/* Scope view (scope-view.tsx): a game tablet's spyglass screen on a plain stage. Every size is a multiple of --u, 1 px at full size (a 1024 × 576 screen), less on a narrower stage; under 640 px the screen takes its 480 × 600 narrow layout, chosen in the component, and --u follows that width. The layout's numbers (lens centre and radius, minimap, gauge…) arrive as unitless custom properties on the screen and are multiplied by --u here. The screen is dark on any page, as a game draws it; only its edge and its shadow on the page, and the focus ring, follow the colour scheme, through light-dark() tokens. No filter touches a glyph or a word: glows are box- and text-shadows behind the sharp art. Motion: the iris is a clip-path transition; the reticle, caret and gauge move by transitions; pins dropping and leaving, the ripple, the refusal shake and the idle shimmer are keyframes. */.root { --cyan: #3cd3fc; --cyan-glow: rgb(60 211 252 / 0.55); --ink: #0e1b31; --navy: #1b1d33; --rail: rgb(128 160 182 / 0.6); --focus-on-screen: #a9f0ff; --edge: rgb(0 0 0 / 0.18); --shade: rgb(14 18 40 / 0.28); --focus: #2f6fdf; position: relative;// Copyright (c) 2026 Bruno Delorence. MIT License.'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. `visible` and `reduced` are returned too, so * idle loops can pause offscreen and motion can be skipped. */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);- The lens is a focusable application region named with its keys: arrows look around (Shift for bigger steps), Q and E (or Page Up, Page Down) zoom, A, Enter or Space place or remove a pin, minus or P change the colour, B or Escape lower the scope, Home resets the view.
- The zoom gauge is a vertical slider with aria-valuetext (“2.4 times”), arrows, Page Up, Page Down, Home and End, and drag. The palette is a radio group of buttons named by colour and state (“Blue, all placed”), with arrow keys between the colours that have pins left.
- A polite live region, which also describes the lens, says what happened and where: “Yellow pin placed on the watchtower. 2 left.”, “Pink pin selected.”, “No pins left…”, “Scope lowered.”
- The hints are buttons with aria-keyshortcuts; the landscape, reticle, bezel and minimap are aria-hidden.
- Every control shows a :focus-visible ring drawn inside the screen, so the bars never hide it; for the lens it is a ring just inside its edge.
- Reduced motion: the view jumps instead of easing, the iris, pins and caret change at once, nothing ripples, shakes or shimmers, and the demo never plays.