Inputs

Coordinates Input

A latitude/longitude pair that reads decimal degrees or DMS in either field, re-prints between the two on a toggle, names the field that leaves ±90 / ±180, and splits a pasted pair across both inputs.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

/** One point on the globe. The two halves travel together — the pair is emitted only when both parse. */
export interface Coordinates {
  lat: number
  lng: number
}

/** How the value is *printed*: "dd" = 48.85837, "dms" = 48°51'30.13"N. Both are always accepted on input. */
export type CoordinateFormat = "dd" | "dms"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/coordinates-input.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CoordinatesInput" component: a latitude /
longitude pair that reads decimal degrees and DMS (lucide-react for the clear
icon; no other runtime dependency).

Contract
- Export interface Coordinates { lat: number; lng: number } and
  type CoordinateFormat = "dd" | "dms". forwardRef lands on the outer div;
  props extend HTMLAttributes<HTMLDivElement> minus defaultValue / onChange.
- Value: controlled (value: Coordinates | null + onValueChange) or uncontrolled
  (defaultValue). onValueChange fires with a Coordinates only when BOTH fields
  parse, and with null the moment either stops — a half-entered pair is never
  emitted as a partial object, and the same pair is never emitted twice (compare
  against a ref holding the last emitted pair, read and written in the handler).
- Notation: controlled (format + onFormatChange) or uncontrolled (defaultFormat,
  default "dd").
- Options: precision (DD decimals, default 5, about 1 m, trunc-clamped 0..8),
  secondsPrecision (default 2, about 0.3 m, clamped 0..4), hemisphere
  "auto" | "sign" | "letter" (default auto = sign in DD, letter in DMS; parsing
  accepts both either way), showFormatToggle, labels {latitude, longitude},
  label (the group's name), description, disabled, name (hidden input carrying
  "lat,lng"), className.

Behavior
- Parsing is one pure function: normalise, tokenise, then fill slots.
  * normalise maps typographic primes, curly quotes, the masculine ordinal, the
    Unicode minus, brackets and non-breaking spaces onto ASCII, so text out of a
    PDF, a wiki table or a GeoJSON snippet all arrive in one shape.
  * the tokeniser emits numbers, unit marks (degree sign, prime, double prime,
    and two apostrophes for seconds), hemisphere letters and separators; any
    other character fails the parse outright.
  * numbers fill degrees, minutes and seconds positionally, and an explicit unit
    mark must match its slot. degrees = |d| + m/60 + s/3600, signed by the letter
    (N/E +1, S/W -1) or by the minus. Number("-0") is -0 and -0 < 0 is false, so
    read the sign off the text as well as off the number.
- Refusals are specific, and none of them rewrites the field: minutes or seconds
  at 60 or more, decimals on anything but the smallest unit present (48.5° 30'
  has two readings), a sign on minutes or seconds, two hemisphere letters, a
  minus and an S together, a number after a suffix letter, more than three
  numbers, one coordinate containing a separator.
- Two clocks for two kinds of wrong. A shape that is merely unfinished stays
  quiet until blur or Enter, because another digit rescues a half-typed 48°51.
  A range or hemisphere problem is shown while typing, because no digit rescues
  91.5 or an E in the latitude field. Both keep the raw text — retyping a whole
  coordinate to fix one digit is the worst thing this field can ask for.
- Range: |lat| <= 90, |lng| <= 180, and the message names the field
  ("Latitude must be between -90° and 90° — got 91.5°"). An E or W typed into
  the latitude field is the same class of error, named the same way. A value
  arriving through props is checked too: flagged, never silently accepted.
- Paste is a ladder of splitters, tried in order, each one only *proposing* a
  cut; the first proposal whose two halves both parse wins:
    1. an explicit separator — "48.8584, 2.2945"
    2. the two hemisphere letters — 48°51'30"N 2°17'40"E, or N48.8584 E2.2945
    3. an even number of whitespace words, cut down the middle — 48 51 30 2 17 40
  Axes come from the letters when they are there, so "2.2945 E, 48.8584 N" still
  lands the right way round, and from the latitude-first convention when they
  are not — unless that reading is out of range while the swapped one is not,
  which is how "151.2153, -33.8568" copied out of GeoJSON is rescued: it has
  exactly one possible reading. Anything the ladder cannot read as two
  coordinates is left to the browser, so pasting a single value into a single
  field keeps working, caret and all. Range is not enforced during the split: an
  out-of-range half still lands in its field, where the normal check names it.
- The toggle re-prints, it never re-enters. Settle any open draft first, then
  regenerate both fields from the committed numbers in the new notation. DMS
  rounding has to carry: 48.99999999° rounds to 59.999…" in the seconds, which
  must become 49°00'00.00" and not 48°59'60.00".
- Keyboard map. ArrowUp / ArrowDown step the focused field by one unit of the
  current notation (10^-precision degrees in DD, one arcsecond in DMS), commit
  immediately, and step off whatever is on screen, including an unsettled draft.
  Shift moves the next unit up: ×10 in DD, one second becomes one minute in DMS.
  A step clamps at the axis limit and announces that it held. Enter settles a
  dirty draft and consumes the press, so a surrounding form is never submitted
  with text that has not been through the parser; a second Enter submits as
  usual. Escape restores what the field held before the draft began, and stops
  propagating so a surrounding dialog does not close on the same key. The DD/DMS
  switch is a radiogroup with a roving tabindex: one tab stop for the group,
  Arrow keys and Home/End move and select, focus follows the selection.
- ARIA contract. The root is role="group" named by the visible label. Each field
  is a plain text input with a real <label htmlFor>, deliberately not
  role="spinbutton" — the value is text (48°51'30"N), not a point on a number
  line. aria-invalid marks only the offending side. aria-describedby points at
  one always-mounted message line, which is *not* a live region because its text
  follows every keystroke; a separate sr-only role="status" announces only the
  events a screen reader would otherwise miss: a settled field, a step, a pasted
  pair, a format switch.
- Inert state is readOnly + aria-disabled with a guard at the top of every
  handler, never the native disabled attribute, so a focused field is not blurred
  to <body> the moment it goes inert. The per-field clear button unmounts itself
  as the field empties, so it focuses the input first and clears second.
- Cleanup: the paste highlight is the only timed thing here. Arm it with an
  incrementing token so a second paste re-arms the timer from scratch, and clear
  it in the effect cleanup, which also runs on unmount.
- Controlled sync: a parent that moves the value on its own (a map click, a form
  reset) replaces both fields and their drafts; a parent that merely echoes back
  what was just emitted must not, or every keystroke would reformat the text
  under the caret. Compare the incoming value against the last emitted pair, not
  against the previous prop. A controlled null falls back to the half-entered
  state the component owns, which is exactly what "no complete pair" means.

Rendering & styling
- Header row: the group label and the DD/DMS radiogroup. Then a flex-wrap row of
  two fields at min-w-40 each, so a container too narrow for both stacks them
  instead of cutting the seconds off a value. Then the message line.
- Fields are h-9 rounded-md border-input shells with focus-within:border-ring
  plus ring-ring/50, and border-destructive plus ring-destructive/30 when
  invalid; the value is tabular-nums so digits do not jitter as they are typed.
- The checked radio is bg-primary / text-primary-foreground; labels, hints and
  the clear icon are text-muted-foreground; refusals are text-destructive; a
  pasted pair flashes border-primary plus bg-primary/5 for about 900 ms. Colour
  transitions only, all switched off under prefers-reduced-motion — the flash is
  decoration, the values are committed with or without it. Semantic tokens only,
  so dark mode comes free.
- inputMode="text", not "decimal": a coordinate carries a degree sign, primes
  and a hemisphere letter, and a numeric keypad hides every one of them.

Customization levers
- Precision: precision drives the DD decimals (5 is about 1 m, 7 about 1 cm) and
  secondsPrecision the DMS seconds. Both also define the arrow-key step, so a
  coarser field steps coarser without a second prop.
- Notation set: the format options are a two-row table. Add a third row (UTM,
  MGRS, an internal grid) by giving it a format/parse pair — the toggle, the
  secondary readout and the announcements all read from that table.
- Hemisphere style: hemisphere="letter" prints 48.858° N in decimal degrees for
  survey-style forms; "sign" keeps -33.857 everywhere for engineering ones.
  Parsing is unaffected, so both dialects stay typeable either way.
- Bounds: one table holds ±90 / ±180. Swap it for a bounding box to keep entries
  inside one country and the same message names the field. For wrap-around
  longitudes, replace the clamp in the step helper with a modulo; the parser
  needs no change.
- Density and chrome: h-9 / text-sm / gap-2 are the sizing knobs;
  showFormatToggle={false} plus labels strips and renames the chrome for an
  inspector sidebar. Drop the secondary readout when a map beside the field
  already shows the other notation.
- Submission: name emits one hidden "lat,lng" input, which the paste splitter
  reads back verbatim. Swap it for two hidden inputs, or drop it entirely and
  drive a form library from onValueChange.

Concepts

  • Emitted only when both parse — the pair is the unit of truth, so the callback never hands back half a point. Every keystroke re-decides it: a field that stops parsing takes the whole pair to null, and an identical pair is never emitted twice, which keeps a controlled parent from ping-ponging.
  • Hemisphere by sign or by letter-33.8568 and 33.8568 S are the same number, and the letter carries something the sign does not: which axis it belongs to. That is what lets a reversed paste, or an E typed into the latitude field, be recognised instead of quietly accepted.
  • Two clocks for two kinds of wrong — an unfinished shape waits for blur or Enter, because the next digit may complete it; a value outside ±90 / ±180 is reported as it is typed, because no further digit will rescue it. Neither rewrites what was typed.
  • The splitter ladder — a pasted pair is cut by an explicit separator, by its two hemisphere letters, or straight down the middle of an even run of numbers, whichever produces two halves that both parse. When neither half names its axis, latitude comes first — unless only the swapped reading fits ±90 / ±180, in which case there is exactly one possible reading and it wins.
  • Re-print, never re-enter — the toggle is a view change, not a data change: drafts are settled first and both fields are regenerated from the committed numbers, so switching notation back and forth is lossless. The rounding carry that makes 48°59'60" impossible lives in the same place.
  • One message line, one live region — the visible line follows every keystroke and is wired through aria-describedby; a separate sr-only role="status" announces only settled commits, steps, pasted pairs and format switches, so a screen reader hears the verdict once instead of on every key.

On This Page