Reads a photo's EXIF and XMP before upload and offers one-tap location removal, so a picture doesn't reveal where and when it was taken.
StopsPhoto leaked my home location
"use client"
import * as React from "react"
import { PhotoLocationGuard } from "@/components/ui/photo-location-guard"
// A tiny little-endian EXIF writer, so the demo can build a real phone-style JPEG
// (GPS, capture time, serial number, embedded preview) without shipping a binary file.
type Value = { type: 1 | 2 | 3 | 4 | 5; data: number[] }
const ascii = (s: string): Value => ({ type: 2, data: [...Array.from(s, (c) => c.charCodeAt(0)), 0] })
const short = (n: number): Value => ({ type: 3, data: [n] })
const long = (n: number): Value => ({ type: 4, data: [n] })
const byte = (n: number): Value => ({ type: 1, data: [n] })
const rational = (...parts: number[]): Value => ({ type: 5, data: parts })
const width = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 4 } as const
const bytesOf = (v: Value) => v.data.length * width[v.type]
const countOf = (v: Value) => (v.type === 5 ? v.data.length / 2 : v.data.length)
function buildExif(ifds: Map<number, Value>[], links: [from: number, tag: number, to: number][], thumbnail: Uint8Array) {
const sizeOf = (ifd: Map<number, Value>) =>
6 + ifd.size * 12 + [...ifd.values()].reduce((sum, v) => sum + (bytesOf(v) > 4 ? bytesOf(v) + (bytesOf(v) % 2) : 0), 0)
for (const [from, tag] of links) ifds[from].set(tag, long(0))
const offsets = ifds.reduce<number[]>((list, ifd, i) => [...list, i ? list[i - 1] + sizeOf(ifds[i - 1]) : 8], [])
const thumbAt = offsets[offsets.length - 1] + sizeOf(ifds[ifds.length - 1])
for (const [from, tag, to] of links) ifds[from].set(tag, long(to < 0 ? thumbAt : offsets[to]))
const out = new DataView(new ArrayBuffer(thumbAt + thumbnail.length))
out.setUint16(0, 0x4949)
out.setUint16(2, 42, true)
out.setUint32(4, 8, true)
ifds.forEach((ifd, i) => {
let at = offsets[i]
let data = at + 6 + ifd.size * 12
out.setUint16(at, ifd.size, true)
at += 2
for (const [tag, v] of [...ifd.entries()].sort((a, b) => a[0] - b[0])) {
out.setUint16(at, tag, true)
out.setUint16(at + 2, v.type, true)
out.setUint32(at + 4, countOf(v), true)
let to = at + 8
if (bytesOf(v) > 4) {
out.setUint32(at + 8, data, true)
to = data
data += bytesOf(v) + (bytesOf(v) % 2)
}
v.data.forEach((n, j) => {
const w = width[v.type]
if (w === 1) out.setUint8(to + j, n)
else if (w === 2) out.setUint16(to + j * 2, n, true)
else out.setUint32(to + j * 4, n, true)
})
at += 12
}
out.setUint32(at, i === 0 ? offsets[3] : 0, true) // IFD0 → IFD1 (the preview)
})
new Uint8Array(out.buffer).set(thumbnail, thumbAt)
return new Uint8Array(out.buffer)
}
function scene(size: number) {
const canvas = document.createElement("canvas")
canvas.width = size
canvas.height = Math.round(size * 0.75)
const ctx = canvas.getContext("2d")!
const { width: w, height: h } = canvas
const sky = ctx.createLinearGradient(0, 0, 0, h)
sky.addColorStop(0, "#f59e0b")
sky.addColorStop(1, "#fde68a")
ctx.fillStyle = sky
ctx.fillRect(0, 0, w, h)
ctx.fillStyle = "#fff7ed"
ctx.beginPath()
ctx.arc(w * 0.7, h * 0.45, h * 0.14, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = "#78350f"
for (const [x, bw, bh] of [[0, 0.18, 0.42], [0.16, 0.14, 0.58], [0.31, 0.2, 0.35], [0.52, 0.12, 0.5], [0.66, 0.2, 0.3], [0.84, 0.16, 0.46]]) {
ctx.fillRect(x * w, h - bh * h, bw * w, bh * h)
}
return new Promise<Uint8Array>((resolve) =>
canvas.toBlob((blob) => void blob!.arrayBuffer().then((b) => resolve(new Uint8Array(b))), "image/jpeg", 0.85),
)
}
/** Builds IMG_2041.jpg: a JPEG with GPS (Shivajinagar, Pune), capture time, serial and preview. */
async function makeSamplePhoto() {
const [photo, thumbnail] = await Promise.all([scene(480), scene(64)])
const ifd0 = new Map([[0x010f, ascii("Apple")], [0x0110, ascii("iPhone 15 Pro")], [0x0112, short(1)]])
const exif = new Map([[0x9003, ascii("2026:10:11 07:42:15")], [0x9011, ascii("+05:30")], [0xa431, ascii("F2LXK8Q7MN")]])
const gps = new Map([
[1, ascii("N")], [2, rational(18, 1, 31, 1, 5088, 100)],
[3, ascii("E")], [4, rational(73, 1, 50, 1, 5100, 100)],
[5, byte(0)], [6, rational(56200, 100)],
])
const ifd1 = new Map([[0x0202, long(thumbnail.length)]])
const tiff = buildExif([ifd0, exif, gps, ifd1], [[0, 0x8769, 1], [0, 0x8825, 2], [3, 0x0201, -1]], thumbnail)
const app1 = new Uint8Array(10 + tiff.length)
app1.set([0xff, 0xe1, (tiff.length + 8) >> 8, (tiff.length + 8) & 0xff, 0x45, 0x78, 0x69, 0x66, 0, 0])
app1.set(tiff, 10)
return new File([photo.slice(0, 2), app1, photo.slice(2)], "IMG_2041.jpg", { type: "image/jpeg", lastModified: 1_760_000_000_000 })
}
// Stand-in for a real reverse geocoder: only knows the sample photo's spot.
const placeName = (lat: number, lon: number) => (Math.abs(lat - 18.53) < 0.01 && Math.abs(lon - 73.85) < 0.01 ? "Shivajinagar, Pune" : "")
export function Demo() {
const [photo, setPhoto] = React.useState<File | null>(null)
const loaded = React.useRef(false)
const load = () => void makeSamplePhoto().then(setPhoto)
React.useEffect(() => {
if (loaded.current) return
loaded.current = true
load()
}, [])
return (
<div className="grid w-full max-w-md gap-3">
<PhotoLocationGuard label="Add a photo to your post" value={photo} onValueChange={setPhoto} describeLocation={placeName} />
<button
type="button"
onClick={load}
className="inline-flex h-6 w-fit items-center rounded-md border bg-background px-2 text-xs text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Reload the sample phone photo
</button>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/ui312 lines"use client" // Bollard UI — photo-location-guard · MIT licence · https://bollard-ui.vercel.app/components/photo-location-guard import * as React from "react" import { Camera, CircleCheck, Clock, ImageUp, Images, LoaderCircle, MapPin, UserRound } from "lucide-react" import { cn } from "@/lib/utils" import { formatCoordinates, hasRevealingMetadata, parseExifDate, readPhotoMetadata, removePhotoMetadata, type PhotoMetadata, } from "@/components/ui/exif" export * from "@/components/ui/exif" const DEFAULT_LABELS = { location: "Location", taken: "Taken", camera: "Camera serial", owner: "Owner name", preview: "Hidden preview", previewText: "May still show the original, uncropped photo.", xmpText: "Location saved by a photo app (XMP).", map: "View on map", } const btn = "inline-flex h-8 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-60" /** "Precise to about 1 m: enough to find the building." */ export function describePrecision(metres: number) { if (metres <= 15) return `Precise to about ${Math.max(1, Math.round(metres))} m: enough to find the building.` if (metres <= 200) return `Precise to about ${Math.round(metres / 10) * 10} m: street level.` return `Approximate, within about ${Math.max(1, Math.round(metres / 1000))} km.` } const formatBytes = (bytes: number) => (bytes < 1e6 ? `${Math.max(1, Math.round(bytes / 1e3))} KB` : `${(bytes / 1e6).toFixed(1)} MB`) const osm = (lat: number, lon: number) => `https://www.openstreetmap.org/?mlat=${lat.toFixed(5)}&mlon=${lon.toFixed(5)}#map=17/${lat.toFixed(5)}/${lon.toFixed(5)}` export interface PhotoLocationGuardProps extends Omit<React.ComponentProps<"div">, "defaultValue"> { /** Controlled photo. */ value?: File | null /** Initial photo when uncontrolled. */ defaultValue?: File | null /** Called with the chosen photo, and with the cleaned copy after "Remove location". */ onValueChange?: (file: File | null) => void /** Optional reverse geocoder, e.g. `(lat, lon) => "Shivajinagar, Pune"`. Without it only coordinates are shown. */ describeLocation?: (latitude: number, longitude: number) => string | Promise<string> /** Map link for the coordinates. Return "" to hide it. */ mapUrl?: (latitude: number, longitude: number) => string /** Locale for the capture time. */ locale?: string /** JPEG quality of the cleaned copy, 0–1. */ quality?: number /** Visible label. */ label?: string /** Button text to choose a photo. */ chooseLabel?: string /** Button text to pick a different photo. */ changeLabel?: string /** Heading over the findings when the photo has a location. */ title?: string /** Heading when there's no location but other details. */ detailsTitle?: string /** Button text to remove location data. */ removeLabel?: string /** Explains what removing does. */ removeHint?: string /** Button text while removing. */ removingLabel?: string /** Shown after location is removed. */ removedText?: string /** Shown when nothing revealing is found. */ cleanText?: string /** Shown when the file can't be read. */ errorText?: string /** Shown for a file type it can't read. */ unsupportedText?: string /** Row names and small texts in the findings list. */ labels?: Partial<typeof DEFAULT_LABELS> } /** * Stops a photo from quietly publishing where you live. It reads the JPEG's EXIF * and XMP before upload and spells out what would leak: the GPS position with how * precise it is, the capture time, the camera's serial number and an embedded * preview that can outlive a crop. "Remove location" re-encodes the photo with its * orientation baked in, then reads the new file again to prove nothing is left. */ export function PhotoLocationGuard({ value, defaultValue = null, onValueChange, describeLocation, mapUrl = osm, locale, quality = 0.92, label = "Photo", chooseLabel = "Choose a photo", changeLabel = "Change", title = "This photo shows where it was taken", detailsTitle = "This photo carries hidden details", removeLabel = "Remove location", removeHint = "Also removes the time, camera details and hidden preview.", removingLabel = "Removing…", removedText = "Location removed. Checked the new file: nothing left to share.", cleanText = "No location or camera details in this photo.", errorText = "Couldn't clean this photo. Try exporting it without location instead.", unsupportedText = "Only JPEG photos are checked for location.", labels: labelOverrides, className, ...props }: PhotoLocationGuardProps) { const [inner, setInner] = React.useState(defaultValue) const file = value === undefined ? inner : value const [meta, setMeta] = React.useState<{ file: File; data: PhotoMetadata | null }>() const [preview, setPreview] = React.useState<string>() const [place, setPlace] = React.useState<string>() const [phase, setPhase] = React.useState<"idle" | "removing" | "error">("idle") const [cleaned, setCleaned] = React.useState<{ file: File; before: number }>() const inputRef = React.useRef<HTMLInputElement>(null) const labels = { ...DEFAULT_LABELS, ...labelOverrides } const id = React.useId() const update = (next: File | null) => { if (value === undefined) setInner(next) onValueChange?.(next) } React.useEffect(() => { setPhase("idle") if (!file) return setPreview(undefined) let live = true const url = URL.createObjectURL(file) setPreview(url) void file.arrayBuffer().then((buffer) => live && setMeta({ file, data: readPhotoMetadata(buffer) })) return () => { live = false URL.revokeObjectURL(url) } }, [file]) const data = meta?.file === file ? meta.data : undefined const gps = data?.gps const lat = gps?.latitude const lon = gps?.longitude React.useEffect(() => { setPlace(undefined) if (lat === undefined || lon === undefined || !describeLocation) return let live = true Promise.resolve(describeLocation(lat, lon)).then( (text) => live && setPlace(text), () => {}, ) return () => { live = false } }, [lat, lon, describeLocation]) const remove = async () => { if (!file) return setPhase("removing") try { const next = await removePhotoMetadata(file, file.name, quality) setCleaned({ file: next, before: file.size }) update(next) } catch { setPhase("error") } } const taken = data?.capturedAt && parseExifDate(data.capturedAt.raw) const takenText = taken && new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit", timeZone: "UTC" }).format(taken) + (data?.capturedAt?.offset ? ` (UTC${data.capturedAt.offset})` : "") const serial = [data?.serial, data?.lensSerial && `lens ${data.lensSerial}`].filter(Boolean).join(", ") const rows = data ? [ gps && { icon: MapPin, name: labels.location, key: "location" }, !gps && data.xmpLocation && { icon: MapPin, name: labels.location, text: labels.xmpText, key: "xmp" }, takenText && { icon: Clock, name: labels.taken, text: takenText, key: "taken" }, serial && { icon: Camera, name: labels.camera, text: [data.camera, `#${serial}`].filter(Boolean).join(" · "), key: "serial" }, data.owner && { icon: UserRound, name: labels.owner, text: data.owner, key: "owner" }, data.thumbnail && { icon: Images, name: labels.preview, text: labels.previewText, key: "preview" }, ].filter((row): row is { icon: typeof MapPin; name: string; text?: string; key: string } => !!row) : [] const warn = data && hasRevealingMetadata(data) && rows.length > 0 const justCleaned = cleaned?.file === file && data !== undefined && !warn const href = gps ? mapUrl(gps.latitude, gps.longitude) : "" return ( <div data-slot="photo-location-guard" className={cn("grid w-full max-w-md gap-1.5 text-sm", className)} {...props}> <style>{`@keyframes cui-photo-location-guard-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}`}</style> <label htmlFor={id} className="w-fit font-medium"> {label} </label> <input ref={inputRef} id={id} type="file" accept="image/jpeg,image/*" className="sr-only" onChange={(event) => { setCleaned(undefined) update(event.target.files?.[0] ?? null) event.target.value = "" }} /> {file ? ( <div className="flex items-center gap-3 rounded-lg border bg-background p-2 shadow-xs dark:bg-input/30"> {preview && <img src={preview} alt="" className="size-12 shrink-0 rounded-md object-cover" />} <div className="min-w-0 flex-1"> <p className="truncate font-medium" title={file.name}> {file.name} </p> <p className="text-xs text-muted-foreground"> {justCleaned && cleaned ? `${formatBytes(cleaned.before)} → ${formatBytes(file.size)}` : formatBytes(file.size)} </p> </div> <button type="button" onClick={() => inputRef.current?.click()} className={cn(btn, "border bg-background hover:bg-accent dark:bg-transparent")}> {changeLabel} </button> </div> ) : ( <button type="button" onClick={() => inputRef.current?.click()} className="flex flex-col items-center gap-2 rounded-lg border border-dashed bg-muted/30 p-6 text-muted-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50" > <ImageUp aria-hidden className="size-5" /> <span className="font-medium text-foreground">{chooseLabel}</span> </button> )} <div aria-live="polite" className="mt-1.5 empty:hidden"> {file && data === null && <p className="text-xs text-muted-foreground">{unsupportedText}</p>} {warn && ( <section aria-label={gps || data.xmpLocation ? title : detailsTitle} className="@container rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-950 dark:text-amber-50" > <h3 className="flex items-start gap-2 font-medium"> <MapPin aria-hidden className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" /> {gps || data.xmpLocation ? title : detailsTitle} </h3> <dl className="mt-2 grid gap-x-3 gap-y-0.5 text-xs @sm:grid-cols-[auto_1fr] @sm:gap-y-1.5"> {rows.map(({ icon: Icon, name, text, key }) => ( <React.Fragment key={key}> <dt className="mt-1 flex items-center gap-1.5 text-amber-900/75 dark:text-amber-100/75"> <Icon aria-hidden className="size-3.5" /> {name} </dt> <dd className="min-w-0 pl-5 @sm:mt-1 @sm:pl-0"> {key === "location" && gps ? ( <> {place && <span className="font-medium">{place} · </span>} <span className="font-medium whitespace-nowrap tabular-nums">{formatCoordinates(gps.latitude, gps.longitude)}</span> {href && ( <a href={href} target="_blank" rel="noreferrer" className="ml-1.5 rounded-sm whitespace-nowrap underline underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"> {labels.map} </a> )} <span className="block text-amber-900/75 dark:text-amber-100/75">{describePrecision(gps.precision)}</span> </> ) : ( <span className="break-words">{text}</span> )} </dd> </React.Fragment> ))} </dl> <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5"> <button type="button" onClick={() => void remove()} disabled={phase === "removing"} aria-busy={phase === "removing" || undefined} className={cn(btn, "bg-amber-700 text-white hover:bg-amber-800 dark:bg-amber-400 dark:text-amber-950 dark:hover:bg-amber-300")} > {phase === "removing" && <LoaderCircle aria-hidden className="size-4 animate-spin" />} {phase === "removing" ? removingLabel : removeLabel} </button> <span className="text-xs text-amber-900/75 dark:text-amber-100/75">{removeHint}</span> </div> {phase === "error" && ( <p role="alert" className="mt-2 text-xs text-red-700 dark:text-red-300"> {errorText} </p> )} </section> )} {data && !warn && ( <p className={cn( "flex items-center gap-1.5 text-xs text-emerald-700 dark:text-emerald-400", justCleaned && "motion-safe:animate-[cui-photo-location-guard-in_300ms_ease-out]", )} > <CircleCheck aria-hidden className="size-3.5 shrink-0" /> {justCleaned ? removedText : cleanText} </p> )} </div> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { PhotoLocationGuard } from "@/components/ui/photo-location-guard"
<PhotoLocationGuard label="Add a photo to your post" value={photo} onValueChange={setPhoto} describeLocation={placeName} />What it catches
- GPS coordinates, with how precise they are
- Capture time and time zone
- Camera serial number and owner name
- Embedded preview that can show the uncropped original
- XMP location fields
Props
| Prop | Type | Default |
|---|---|---|
valueControlled photo. | File | null | — |
defaultValueInitial photo when uncontrolled. | File | null | null |
onValueChangeCalled with the chosen photo, and with the cleaned copy after "Remove location". | (file: File | null) => void | — |
describeLocationOptional reverse geocoder, e.g. `(lat, lon) => "Shivajinagar, Pune"`. Without it only coordinates are shown. | (latitude: number, longitude: number) => string | Promise<string> | — |
mapUrlMap link for the coordinates. Return "" to hide it. | (latitude: number, longitude: number) => string | osm |
localeLocale for the capture time. | string | — |
qualityJPEG quality of the cleaned copy, 0–1. | number | 0.92 |
labelVisible label. | string | "Photo" |
chooseLabelButton text to choose a photo. | string | "Choose a photo" |
changeLabelButton text to pick a different photo. | string | "Change" |
titleHeading over the findings when the photo has a location. | string | "This photo shows where it was taken" |
detailsTitleHeading when there's no location but other details. | string | "This photo carries hidden details" |
removeLabelButton text to remove location data. | string | "Remove location" |
removeHintExplains what removing does. | string | "Also removes the time, camera details and hidden preview." |
removingLabelButton text while removing. | string | "Removing…" |
removedTextShown after location is removed. | string | "Location removed. Checked the new file: nothing left to … |
cleanTextShown when nothing revealing is found. | string | "No location or camera details in this photo." |
errorTextShown when the file can't be read. | string | "Couldn't clean this photo. Try exporting it without loca… |
unsupportedTextShown for a file type it can't read. | string | "Only JPEG photos are checked for location." |
labelsRow names and small texts in the findings list. | Partial<typeof DEFAULT_LABELS> | — |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- File input has a visible label and buttons to choose or change
- Findings are in a polite live region; the details list is labelled
- Remove shows aria-busy while working; errors use role=alert
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
This is a UX guard rail. Enforce on the server: Strip metadata on the server too before publishing uploads; a client-side strip can be skipped.
- Reads JPEG metadata; other formats show an unsupported notice
- Re-encoding drops all metadata and slightly changes image quality
- Can't remove location visible in the picture itself (street signs, landmarks)
What it stops
Photo leaked my home location
How people describe it