I shipped a small side project last week, a Rice Purity Test, and a couple of the build details turned out more interesting than the quiz itself. Two in particular: making the result screen recolor itself based on your score, and exporting a share card to PNG entirely in the browser. Notes below in case they save someone an hour.
The setup
It's a Next.js 16 app on the App Router, deployed to Cloudflare Workers via OpenNext. The quiz is one client component (an "island") sitting inside otherwise static, server-rendered pages, so the SEO content stays fast and only the interactive part hydrates. 100 checkbox questions; your score is 100 minus whatever you tick.
Persona-adaptive theming
This was the fun part. Instead of one result screen, the page picks a colour identity from your score band and drives everything off a single CSS custom property.
:root { --persona: var(--sage); } /* high score, reserved */
[data-band="wild"] { --persona: var(--crimson); } /* low score */
The result component sets a data-band attribute plus the accent colour, and the card background, the borders, even the shadow tint all follow from --persona. No conditional class soup. Ten bands, one variable.
const band = bandForScore(score); // 0..9
return <section data-band={band.key} style={{ "--persona": band.color }}>...</section>;
Setting a CSS var inline in React like that is underused. You compute a theme value in JS and hand it straight to plain CSS, no styled-components, no re-render storm.
Exporting the share card as a PNG
People want to post their score, so the card has to become an image. I used html-to-image and dynamic-imported it so it never touches the initial bundle:
async function savePng(node) {
await document.fonts.ready; // gotcha #1
const { toPng } = await import("html-to-image");
const url = await toPng(node, { pixelRatio: 2, cacheBust: true }); // gotcha #2
const a = document.createElement("a");
a.href = url; a.download = "rice-purity-score.png"; a.click();
}
Two things ate that hour. Web fonts must be fully loaded before you rasterize or the card renders in a fallback font, hence await document.fonts.ready. And the default export looks soft on retina, so pixelRatio: 2.
i18n without moving indexed URLs
I added five languages after launch. English stays at the root (unprefixed) so nothing already indexed moves; other locales get a prefix (/es, /pt, and so on). One dictionary file per locale, all matching the same TypeScript Dict shape, and the client quiz reads the dict as plain serializable data. hreflang and canonical both generate from a single LOCALES array, so adding a language is basically: write the dict, append the code, deploy.
If you want to poke at the finished thing, it's live at ricepuritytest.art, and the score-meaning breakdown is where those band colours come from. Happy to get into any of the build details in the comments.
Top comments (0)