I run a small quiz site (a Rice Purity Test), and people mostly share their result as a screenshot in an Instagram or Snapchat story. So I added a "certificate": after the quiz, the browser draws a 1080×1920 PNG with the score on it, and a Share button hands it to the phone's share sheet.
Drawing the image was the easy part. Getting the share sheet to behave on iOS took more care. Here's what mattered, in case you're building something similar.
1. Generate the image before the tap, not after
The tempting flow is: user taps Share → draw the canvas → toBlob() → navigator.share(). On iOS Safari that's a trap. navigator.share() only works while the tap's user activation is still valid, and awaiting a canvas render (plus web fonts, see below) can use that up.
So the image is rendered as soon as the result appears:
useEffect(() => {
let cancelled = false;
let url: string | null = null;
renderCertificate(cert)
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setState({ status: 'ready', cert, blob, url });
})
.catch(() => {
if (!cancelled) setState({ status: 'failed', cert });
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [cert]);
The File is built ahead of time too, so the click handler reaches navigator.share() without awaiting anything:
const file = useMemo(
() => (blob ? new File([blob], fileName, { type: 'image/png' }) : null),
[blob, fileName],
);
A side benefit: the object URL doubles as the src of a small thumbnail <img>, so iPhone users can also long-press it and save it without touching the button.
2. Share the file on its own
The obvious call is navigator.share({ files: [file], text, url }). On iOS, some share targets then take the link instead of the picture, and "Save Image" disappears from the sheet. Sharing only the file fixed it:
navigator.share({ files: [file] }).catch((error) => {
if (!isHarmless(error)) downloadFile(url, fileName);
});
The site address is printed at the bottom of the certificate instead, so the image still points back to the site wherever it ends up.
3. Feature-detect with canShare, fall back to a download
Most desktop browsers can't share files. navigator.canShare({ files: [file] }) tells you up front, and the button label switches between "Share" and "Save image":
const canShareFiles =
typeof navigator.share === 'function' &&
typeof navigator.canShare === 'function' &&
navigator.canShare({ files: [file] });
The download fallback is the classic temporary anchor:
function downloadFile(url: string, fileName: string) {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
}
4. Not every rejection is an error
navigator.share() rejects when the user simply closes the sheet (AbortError), and also when they tap Share again while the sheet is still open (InvalidStateError). Neither should trigger a download, so both are ignored:
function isHarmless(error: unknown): boolean {
const name = (error as { name?: unknown } | null)?.name;
return name === 'AbortError' || name === 'InvalidStateError';
}
Anything else falls back to downloading the image.
5. Canvas doesn't wait for your web fonts
Canvas draws with whatever font is available at that moment. If the web font hasn't loaded yet, you silently get the fallback. Two things made this work with Next.js:
-
next/fontgives each font a hashed family name. The layout exposes it as a CSS variable on<html>, so the canvas code reads that variable instead of hard-coding a family name. - Before drawing, it waits for
document.fonts.load()for each face, capped at 3 seconds. After that it draws anyway, because a certificate in Georgia is better than no certificate.
function siteFonts() {
const style = getComputedStyle(document.documentElement);
const family = (variable: string, fallback: string) => {
const value = style.getPropertyValue(variable).trim();
return value ? `${value}, ${fallback}` : fallback;
};
const display = family('--font-fraunces', 'Georgia, serif');
// …the body and mono faces are read the same way
return { display: (px: number) => `600 ${px}px ${display}` /* , body, mono */ };
}
async function waitForFonts(fonts) {
if (typeof document.fonts?.load !== 'function') return;
const loads = [fonts.display, fonts.body, fonts.mono].map((at) =>
document.fonts.load(at(48)).catch(() => []),
);
await Promise.race([
Promise.all(loads),
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
}
6. Fitting text without a loop
Some lines are long (the site also has a French version, and labels like "Très expérimenté" run wider), so each line shrinks to fit the frame. Text width grows linearly with font size, so there's no need to step the size down in a loop. One measurement is enough:
export function fitFontSize(measure, text, maxWidth, startPx, minPx) {
const width = measure(text, startPx);
if (width <= maxWidth) return startPx;
return Math.max(minPx, Math.floor((startPx * maxWidth) / width));
}
measure is passed in instead of calling the canvas directly, so the layout maths can be unit-tested in jsdom, which has no canvas.
If drawing fails, nothing breaks
If there's no canvas, toBlob() returns null, or anything throws, the certificate block isn't rendered at all and the result card falls back to the old text-and-link share button. Nobody sees a broken image.
You can try it at ricepuritytestup.com: finish the quiz and the certificate appears under your score. (It's my site. The questions are personal yes/no ones, but everything is scored in your browser and nothing you answer is sent anywhere.)
Top comments (0)