Says what a number really is before you call it back, so a Caribbean, premium-rate or one-ring scam number isn't mistaken for a local one.
StopsCalled back a premium-rate scam number
"use client"
import * as React from "react"
import type { CountryCode } from "libphonenumber-js/max"
import { cn } from "@/lib/utils"
import { CallbackNumberCheck } from "@/components/ui/callback-number-check"
const homes: { code: CountryCode; label: string }[] = [
{ code: "US", label: "US" },
{ code: "IN", label: "India" },
{ code: "GB", label: "UK" },
]
const calls = [
{ id: "missed", number: "+1 876 555 0142", caption: "Missed call · 1 ring · 2:13 AM", rings: 1 },
{ id: "sms", number: "+44 909 879 0000", caption: "Text: “You've won! Call to claim your prize”" },
{
id: "bank",
number: "+91 98201 12345",
caption: "Voicemail from “HDFC Bank fraud team”",
officialName: "HDFC Bank",
officialNumbers: ["1800 202 6161", "1800 266 4332"],
},
]
export function Demo() {
const [home, setHome] = React.useState<CountryCode>("US")
const [log, setLog] = React.useState("")
return (
<div className="grid w-full max-w-md gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span id="cnc-home">Your phone is in</span>
<div role="radiogroup" aria-labelledby="cnc-home" className="flex gap-1 rounded-lg bg-muted p-1">
{homes.map((h) => (
<button
key={h.code}
type="button"
role="radio"
aria-checked={home === h.code}
onClick={() => setHome(h.code)}
className={cn(
"h-7 rounded-md px-2.5 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50",
home === h.code ? "bg-background text-foreground shadow-xs" : "hover:text-foreground",
)}
>
{h.label}
</button>
))}
</div>
</div>
{calls.map((call) => (
<CallbackNumberCheck
key={call.id}
number={call.number}
caption={call.caption}
rings={call.rings}
officialName={call.officialName}
officialNumbers={call.officialNumbers}
homeCountry={home}
onCall={(e164) => setLog(`Dialling ${e164}…`)}
onDismiss={() => setLog("Not calling. Good call.")}
/>
))}
<p role="status" className="min-h-4 text-xs text-muted-foreground">
{log}
</p>
</div>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/ui272 lines"use client" // Bollard UI — callback-number-check · MIT licence · https://bollard-ui.vercel.app/components/callback-number-check import * as React from "react" import type { CountryCode } from "libphonenumber-js/max" import { Ban, Globe, Phone, PhoneMissed, ShieldAlert, TriangleAlert } from "lucide-react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { checkCallback, countryName, type CallbackFindingKind } from "./callback" export { checkCallback, countryName } from "./callback" export type { CallbackCheck, CallbackCheckOptions, CallbackFinding, CallbackFindingKind } from "./callback" /** Every visible string. Placeholders: {country} {home} {code} {area} {name}. */ export const CALLBACK_NUMBER_CHECK_MESSAGES = { findings: { unreadable: "This doesn't read as a phone number.", "not-assigned": "No phone company has been given this range. It may be spoofed or mistyped.", "lookalike-domestic": "{area} looks like a {home} area code, but it's {country}. You'd pay international rates.", international: "Calling {country} (+{code}) is an international call.", "one-ring": "It rang once and hung up. One-ring scams use foreign numbers to bill you for calling back.", premium: "Premium rate: the line itself charges per minute, on top of your normal call cost.", "shared-cost": "Shared-cost line: often charged above a normal call and not covered by bundled minutes.", "foreign-toll-free": "Toll-free only inside {country}. From {home} it's an international call that may be charged.", voip: "An internet (VoIP) number. Anyone can get one anywhere, so the country says little about who it is.", "not-official": "Not one of {name}'s published numbers. Call the number on their website or card instead.", } satisfies Record<CallbackFindingKind, string>, types: { MOBILE: "Mobile", FIXED_LINE: "Landline", FIXED_LINE_OR_MOBILE: "Phone", TOLL_FREE: "Toll-free", PREMIUM_RATE: "Premium rate", SHARED_COST: "Shared cost", UAN: "Business number", VOIP: "Internet number", PERSONAL_NUMBER: "Personal number", PAGER: "Pager", VOICEMAIL: "Voicemail", } as Record<string, string>, call: "Call", callAnyway: "Call anyway", confirmHint: "Press again to call. Check the cost first.", dontCall: "Don't call", nonGeographic: "Worldwide number", worldwide: "a worldwide number", safe: "Nothing unusual about this number.", findingsLabel: "Before you call", } export type CallbackNumberCheckMessages = typeof CALLBACK_NUMBER_CHECK_MESSAGES export interface CallbackNumberCheckProps extends Omit<React.ComponentProps<"div">, "children"> { /** The number as you received it: "+1 876 555 0142", "0909 879 0000", "1800-266-0123". */ number: string /** Country the caller's phone is in. Defaults to the browser language's region, then "US". */ homeCountry?: CountryCode /** Country to read a number without + in. Defaults to `homeCountry`. */ defaultCountry?: CountryCode /** Rings before a missed call hung up; 1 flags the one-ring pattern. */ rings?: number /** Who the number claims to be ("HDFC Bank"); used with `officialNumbers`. */ officialName?: string /** That organisation's published numbers. A number not on the list must be confirmed. */ officialNumbers?: string[] /** Where it came from: "Missed call · 2:13 AM". */ caption?: React.ReactNode /** Called with the E.164 number when the call actually starts. The link still dials `tel:`. */ onCall?: (e164: string) => void /** Called when the user picks "Don't call" on a risky number. */ onDismiss?: () => void /** Language for country names. */ locale?: string /** Override any visible text. */ messages?: Partial<Omit<CallbackNumberCheckMessages, "findings" | "types">> & { findings?: Partial<CallbackNumberCheckMessages["findings"]> types?: Partial<CallbackNumberCheckMessages["types"]> } } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m) /** * Stops expensive and scam callbacks. Next to a number you're about to call back it says what * the number really is (country, line type) from Google's libphonenumber data, and flags the * traps: a Caribbean +1 876 number that passes for a US area code, a one-ring missed call from * abroad, premium-rate and shared-cost lines, a toll-free number that is only free in its own * country, unassigned ranges and a "bank" number that isn't on the bank's own list. Risky calls * need a second, deliberate press: the Call button morphs into "Call anyway" in place, so focus * never moves and nobody dials by reflex. */ export function CallbackNumberCheck({ number, homeCountry, defaultCountry, rings, officialName, officialNumbers, caption, onCall, onDismiss, locale, messages, className, ...props }: CallbackNumberCheckProps) { const [detected, setDetected] = React.useState<CountryCode>() const [armed, setArmed] = React.useState(false) const reduceMotion = useReducedMotion() const statusId = React.useId() const m = { ...CALLBACK_NUMBER_CHECK_MESSAGES, ...messages, findings: { ...CALLBACK_NUMBER_CHECK_MESSAGES.findings, ...messages?.findings }, types: { ...CALLBACK_NUMBER_CHECK_MESSAGES.types, ...messages?.types }, } React.useEffect(() => { if (homeCountry) return const region = new Intl.Locale(navigator.language).maximize().region setDetected(region && /^[A-Z]{2}$/.test(region) ? (region as CountryCode) : "US") }, [homeCountry]) const home = homeCountry ?? detected ?? "US" const check = React.useMemo( () => checkCallback(number, { homeCountry: home, defaultCountry, rings, officialNumbers }), [number, home, defaultCountry, rings, officialNumbers], ) React.useEffect(() => setArmed(false), [check.e164]) const risk = check.findings.some((f) => f.severity === "stop") ? "stop" : check.findings.length ? "caution" : "ok" const national = check.e164 && check.callingCode ? check.e164.slice(check.callingCode.length + 1) : "" const values = { country: countryName(check.country, locale) ?? m.worldwide, home: countryName(home, locale) ?? home, code: check.callingCode ?? "", area: national.slice(0, 3), name: officialName ?? "the organisation", } const spring = reduceMotion ? { duration: 0.12 } : { type: "spring" as const, stiffness: 420, damping: 32 } return ( <div data-slot="callback-number-check" data-risk={risk} className={cn( "grid w-full min-w-0 gap-3 rounded-xl border bg-card p-4 text-sm text-card-foreground", risk === "stop" && "border-red-500/40 dark:border-red-400/40", className, )} {...props} > <div className="flex min-w-0 items-start gap-3"> {/* The ISO code, not a flag emoji: Windows has no flag glyphs and would show the same letters anyway. */} <span aria-hidden className={cn( "flex size-10 shrink-0 items-center justify-center rounded-full bg-muted font-mono text-xs font-semibold text-muted-foreground", check.country && check.country !== home && "bg-amber-500/15 text-amber-800 dark:text-amber-300", )} > {check.e164 ? (check.country ?? <Globe className="size-4" />) : <PhoneMissed className="size-4" />} </span> <div className="min-w-0 flex-1"> {caption && <p className="text-xs text-muted-foreground">{caption}</p>} <p className="truncate font-mono text-base font-semibold tabular-nums" title={check.display}> {check.display || number} </p> {check.e164 && ( <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1"> <Globe aria-hidden className="size-3" /> {countryName(check.country, locale) ?? m.nonGeographic} </span> {check.type && ( <span className={cn( "rounded-full border px-1.5 py-px font-medium", check.type === "PREMIUM_RATE" && "border-red-500/40 text-red-700 dark:text-red-400", )} > {m.types[check.type] ?? check.type} </span> )} </p> )} </div> </div> {check.findings.length > 0 ? ( <ul aria-label={m.findingsLabel} className="grid gap-1.5"> <AnimatePresence initial={false}> {check.findings.map((finding, index) => { const Icon = finding.kind === "not-official" ? ShieldAlert : finding.severity === "stop" ? Ban : TriangleAlert return ( <motion.li key={finding.kind} layout={!reduceMotion} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, transition: { duration: 0.12 } }} transition={{ ...spring, delay: reduceMotion ? 0 : index * 0.05 }} className={cn( "flex gap-2 rounded-md px-2.5 py-1.5 text-xs", finding.severity === "stop" ? "bg-red-500/10 text-red-900 dark:text-red-200" : "bg-amber-500/10 text-amber-900 dark:text-amber-200", )} > <Icon aria-hidden className="mt-px size-3.5 shrink-0" /> <span>{fill(m.findings[finding.kind], values)}</span> </motion.li> ) })} </AnimatePresence> </ul> ) : ( <p className="text-xs text-muted-foreground">{m.safe}</p> )} {check.e164 && ( <div className="flex flex-wrap items-center justify-end gap-2"> {risk === "stop" && armed && onDismiss && ( <button type="button" onClick={onDismiss} className="inline-flex h-9 items-center rounded-md px-3 text-sm font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {m.dontCall} </button> )} <motion.a layout={!reduceMotion} transition={spring} href={`tel:${check.e164}`} aria-describedby={armed ? statusId : undefined} onClick={(event) => { if (risk === "stop" && !armed) { event.preventDefault() setArmed(true) return } onCall?.(check.e164!) }} className={cn( "inline-flex h-9 items-center gap-1.5 overflow-hidden rounded-md px-4 text-sm font-medium whitespace-nowrap outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", armed ? "bg-destructive text-white hover:bg-destructive/90" : "bg-primary text-primary-foreground hover:bg-primary/90", )} > <Phone aria-hidden className="size-4" /> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={armed ? "armed" : "idle"} initial={{ opacity: 0, y: reduceMotion ? 0 : 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduceMotion ? 0 : -10 }} transition={spring} > {armed ? m.callAnyway : m.call} </motion.span> </AnimatePresence> </motion.a> </div> )} <p id={statusId} role="status" className={cn("text-right text-xs text-muted-foreground", !armed && "sr-only")}> {armed ? m.confirmHint : ""} </p> </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 { CallbackNumberCheck } from "@/components/ui/callback-number-check"
<CallbackNumberCheck
key={call.id}
number={call.number}
caption={call.caption}
rings={call.rings}
officialName={call.officialName}
officialNumbers={call.officialNumbers}
homeCountry={home}
onCall={(e164) => setLog(`Dialling ${e164}…`)}
onDismiss={() => setLog("Not calling. Good call.")}
/>What it catches
- +1 numbers in Caribbean countries that look like US area codes (e.g. 876 Jamaica)
- One-ring missed calls from abroad (wangiri pattern)
- Premium-rate and shared-cost lines
- Toll-free numbers that are only free in their own country, and unassigned ranges
- A number claiming to be an organisation that isn't in the officialNumbers you pass
Props
| Prop | Type | Default |
|---|---|---|
number* (required)The number as you received it: "+1 876 555 0142", "0909 879 0000", "1800-266-0123". | string | — |
homeCountryCountry the caller's phone is in. Defaults to the browser language's region, then "US". | CountryCode | — |
defaultCountryCountry to read a number without + in. Defaults to `homeCountry`. | CountryCode | — |
ringsRings before a missed call hung up; 1 flags the one-ring pattern. | number | — |
officialNameWho the number claims to be ("HDFC Bank"); used with `officialNumbers`. | string | — |
officialNumbersThat organisation's published numbers. A number not on the list must be confirmed. | string[] | — |
captionWhere it came from: "Missed call · 2:13 AM". | React.ReactNode | — |
onCallCalled with the E.164 number when the call actually starts. The link still dials `tel:`. | (e164: string) => void | — |
onDismissCalled when the user picks "Don't call" on a risky number. | () => void | — |
localeLanguage for country names. | string | — |
messagesOverride any visible text. | Partial<Omit<CallbackNumberCheckMessages, "findings" | "types">> & { findings?: Partial<CallbackNumberCheckMessages["findings"]> types?: … | — |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Findings are a labelled list with icon plus text
- The Call button is described by the status once armed for a second press
- Status is announced in a role="status" region (visually hidden until armed)
- Motion respects prefers-reduced-motion
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Line types come from libphonenumber metadata, which can lag behind real number allocations
- It cannot tell who owns a number or detect a spoofed caller ID
- Risky calls still go through after a second press
What it stops
Called back a premium-rate scam number
How people describe it
Also known as
phone number · callback · libphonenumber · +91 · missed call · scam call · toll free · call button
Field type