Open iMessage from your website
Add a button that opens iMessage with a pre-filled number and message | iMessage for Business
An sms: link opens the visitor’s Messages app with the phone number and message already filled in. On an iPhone or Mac texting an iMessage-enabled number, the thread opens blue. This guide gives you two drop-in patterns:
- Plain button — one
<a>tag, no form. - Progressive form — phone first, then first/last name and submit. Same flow Sendblue uses on its demo page.
How the sms: URL works
Section titled “How the sms: URL works”sms:+15551234567&body=Hello%20from%20Sendblue
(Click it — on an Apple device this opens Messages with both fields filled in.)
- The number must be in E.164 format (the
+is an international-dialing prefix that virtually every API, including Sendblue, requires). - The body must be URL-encoded — use
encodeURIComponent()in JavaScript. - Separator quirk. RFC 5724 says the separator should be
?body=, and Android follows it. iOS has always used&body=instead. The hybrid?&body=is a community workaround that reportedly works on both, but no spec defines it — for iPhone visitors, use&body=.
Version 1: Plain button
Section titled “Version 1: Plain button”Live demo — click to open Messages with the number and body prefilled:
Drop this into any HTML page. Replace the number and message with your own.
<a href="sms:+15551234567&body=Hi%21%20I%27d%20like%20to%20learn%20more%20about%20Sendblue." class="imessage-button"> Message us on iMessage</a>
<style> .imessage-button { display: inline-flex; align-items: center; padding: 12px 24px; background: #3b82f6; color: white; border-radius: 9999px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: 500; text-decoration: none; } .imessage-button:hover { background: #2563eb; }</style>On iOS and macOS, Messages opens with your number and message prefilled. On Android, the default SMS app opens.
Version 2: Progressive form
Section titled “Version 2: Progressive form”Ask for a phone number first. Once the visitor has typed a few digits, reveal the name fields and a Submit button — then validate the full number on submit before opening iMessage with a personalized message.
One nice thing about the form is that it gets browser autofill for free. The autocomplete="tel", "given-name", and "family-name" attributes let Chrome, Safari, and most mobile browsers fill everything with a single tap from the visitor’s saved contact info. The prefilled iMessage body can then be personalized (“Hi, I’m Ada…”) using what they entered.
Live demo — type a phone number (at least 5 digits) to reveal the name fields and submit button:
The code below uses libphonenumber-js for parsing, validation, and as-you-type formatting — no phone-input wrapper component. The phone field is a plain <input type="tel" autoComplete="tel"> paired with a native <select> for the country. That keeps Chrome’s autofill working perfectly (it only reliably offers contact-profile autofill on stock inputs with the right autocomplete attributes) and keeps the dep count to one.
Install dependencies
Section titled “Install dependencies”npm install libphonenumber-jsComponent
Section titled “Component”"use client";
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent,} from "react";import parsePhoneNumberFromString, { isValidPhoneNumber, getCountries, getCountryCallingCode, formatIncompletePhoneNumber, type CountryCode,} from "libphonenumber-js";
const SENDBLUE_NUMBER = "+15551234567"; // your Sendblue number
// ISO 3166-1 alpha-2 → regional-indicator emoji flagfunction isoToFlag(iso: string) { const A = 0x1f1e6 - "A".charCodeAt(0); return String.fromCodePoint( A + iso.toUpperCase().charCodeAt(0), A + iso.toUpperCase().charCodeAt(1), );}
const COUNTRIES = getCountries() .map((iso) => ({ iso, dial: getCountryCallingCode(iso), flag: isoToFlag(iso), })) .sort((a, b) => a.iso.localeCompare(b.iso));
export function IMessageForm() { const [country, setCountry] = useState<CountryCode>("US"); const [national, setNational] = useState(""); // digits only, no dial code const [first, setFirst] = useState(""); const [last, setLast] = useState(""); const [phoneError, setPhoneError] = useState(false); const prevDigitsLenRef = useRef(0);
const dial = getCountryCallingCode(country); const digits = national.replace(/\D/g, ""); const e164 = digits ? `+${dial}${digits}` : ""; const phoneValid = e164 ? isValidPhoneNumber(e164) : false; const revealed = digits.length >= 5;
// Format as the visitor types: "9173599290" → "(917) 359-9290" for US. const formatted = useMemo( () => (digits ? formatIncompletePhoneNumber(digits, country) : ""), [digits, country], );
function handlePhoneChange(e: ChangeEvent<HTMLInputElement>) { const v = e.target.value; setPhoneError(false); const rawDigits = v.replace(/\D/g, ""); const prev = prevDigitsLenRef.current; prevDigitsLenRef.current = rawDigits.length;
// Explicit E.164 pasted ("+...") → parse, switch country, strip dial code. if (v.trim().startsWith("+")) { const parsed = parsePhoneNumberFromString(v); if (parsed?.country && parsed.nationalNumber) { setCountry(parsed.country); setNational(String(parsed.nationalNumber)); return; } }
// Sudden jump in digit count = paste/autofill. Try parsing as E.164 // and strip the dial code if detected. Prefer keeping the user's // currently-selected country when the digits start with its dial code. if (rawDigits.length - prev > 1) { const parsed = parsePhoneNumberFromString("+" + rawDigits); if ( parsed?.country && parsed.nationalNumber && rawDigits.length > String(parsed.nationalNumber).length ) { if (parsed.country !== country && !rawDigits.startsWith(dial)) { setCountry(parsed.country); } setNational(String(parsed.nationalNumber)); return; } }
setNational(rawDigits); }
function handleSubmit(e: FormEvent) { e.preventDefault(); if (!phoneValid) { setPhoneError(true); return; } const name = [first, last].filter(Boolean).join(" ").trim() || "there"; const body = `Hi! I'm ${name}, I'd like to learn more about Sendblue.`; window.location.href = `sms:${SENDBLUE_NUMBER}&body=${encodeURIComponent(body)}`; }
return ( <form onSubmit={handleSubmit} className="imessage-form" noValidate> <label className="field"> <span>Your phone number</span> <div className="phone-field"> <div className="country-pill"> <span>{isoToFlag(country)}</span> <span>+{dial}</span> <select aria-label="Country" value={country} onChange={(e) => setCountry(e.target.value as CountryCode)} > {COUNTRIES.map((c) => ( <option key={c.iso} value={c.iso}> {c.flag} {c.iso} +{c.dial} </option> ))} </select> </div> <input name="phone" type="tel" inputMode="tel" autoComplete="tel" placeholder="(555) 000-0000" value={formatted} onChange={handlePhoneChange} onBlur={() => { if (digits && !phoneValid) setPhoneError(true); }} required /> </div> {phoneError && <span className="error">Invalid phone number</span>} </label>
<div className={`reveal${revealed ? " is-open" : ""}`}> <div className="name-row"> <label className="field"> <span>First name</span> <input name="given-name" type="text" value={first} onChange={(e) => setFirst(e.target.value)} autoComplete="given-name" /> </label> <label className="field"> <span>Last name</span> <input name="family-name" type="text" value={last} onChange={(e) => setLast(e.target.value)} autoComplete="family-name" /> </label> </div> <button type="submit" className="submit-btn" disabled={!phoneValid}> Open iMessage </button> </div> </form> );}Why it’s structured this way
Section titled “Why it’s structured this way”- Plain
<input type="tel" autoComplete="tel">. The phone input has no library wrapper, which is what Chrome’s profile-autofill scanner expects — clicking it offers the whole contact (phone + first + last) in one tap. formatIncompletePhoneNumberfor display. We store raw national digits in state and format them at render time. Pasting+19173599290parses cleanly; typing9173599290formats to(917) 359-9290as the visitor goes.- Progressive reveal without breaking autofill. The name fields and Submit live inside a wrapper that sits at
position: absolute; left: -99999pxon first load — still rendered with real dimensions, so Chrome indexes them for autofill, but visually hidden until the visitor has typed a few digits. The wrapper flips toposition: staticwhen the reveal opens.
Minimal styles
Section titled “Minimal styles”.imessage-form { display: flex; flex-direction: column; gap: 16px; max-width: 460px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;}.field { display: flex; flex-direction: column; gap: 6px; font-size: 14px; color: #374151;}.phone-field { display: flex; align-items: stretch; height: 40px; border: 1px solid #d1d5db; border-radius: 12px; overflow: hidden;}.phone-field:focus-within { border-color: #007aff; box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);}.country-pill { position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border-right: 1px solid #d1d5db; cursor: pointer; user-select: none;}.country-pill select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; appearance: none; -webkit-appearance: none; border: 0;}.phone-field input { flex: 1; min-width: 0; padding: 0 14px; border: 0; background: transparent; font-size: 16px; outline: none;}.field input[type="text"] { height: 40px; padding: 0 14px; border: 1px solid #d1d5db; border-radius: 12px; font-size: 16px;}.name-row { display: flex; gap: 12px;}.name-row .field { flex: 1;}.error { color: #dc2626; font-size: 13px;}/* Keep the name fields rendered at real dimensions off-screen so browser autofill still indexes them, even before the progressive reveal opens. */.reveal { position: absolute; left: -99999px; opacity: 0; pointer-events: none; display: flex; flex-direction: column; gap: 16px; transition: opacity 0.22s ease;}.reveal.is-open { position: static; opacity: 1; pointer-events: auto;}.submit-btn { height: 40px; padding: 0 24px; background: linear-gradient(180deg, #34aadc 0%, #007aff 100%); color: white; border: 0; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer;}.submit-btn:disabled { opacity: 0.5; cursor: not-allowed;}Optional: capture the contact before opening iMessage
Section titled “Optional: capture the contact before opening iMessage”In handleSubmit, POST the phone and name to your backend or CRM before setting window.location.href. That way you keep the contact’s data even if the visitor never hits send in Messages.
await fetch("/api/contacts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone, firstName, lastName }),});window.location.href = `sms:${SENDBLUE_NUMBER}&body=${encodeURIComponent(body)}`;Gotchas
Section titled “Gotchas”- Desktop browsers on non-Apple machines won’t do anything useful with
sms:links. Detect the platform and hide the button, or fall back to a different channel. - Query-parameter separator. See the RFC vs. iOS quirk above — if you want one link to work everywhere,
?&body=is the workaround most sites use, but it isn’t standardized. - Keep the prefilled body short. Browsers and mobile WebViews enforce URL-length limits (often a few thousand characters), and very long encoded URLs can fail or be truncated before they ever reach the Messages app. A short, friendly opener is more reliable than a paragraph.