Ask our homepage how many mascots it is carrying:
curl -s https://pub-trivia.app/ | grep -c 'viewBox="0 0 120 120"' # 5 Five. Every mascot in this codebase is drawn on the same 120 by 120 grid, so that one grep finds all of them, and the homepage at pub-trivia.app is the only one of our 79 published pages that has any. I fetched all 79 from the sitemap to check: the other 78 have none.
This is a post about what it costs to put a cartoon in a product, measured, including the part where four characters were drawn and only one of them is in production.
Four candidates, and a note in the code saying three of them can go
The character set is a typed list, with each entry carrying its own argument for why it should win:
export type MascotDefinition = { id: MascotId name: string kind: string pitch: string bestFor: string watchOut: string /** A soft backdrop for showing the character off. */ stage: string } /** The candidates. Once one is chosen the others can go. */ export const MASCOTS: readonly MascotDefinition[] = [ /* ... */ ] The four are Froth the pint, Biscuit the pub dog, Matt the beer mat and Watt the lightbulb. Each one's watchOut field is the honest case against it, written at the same time as the pitch. The pint's is the sharpest:
A pint needs care at student unions, charities and work events. Never shown drinking.
The dog's is "less tied to the brand than the pint". The beer mat's is that it is flat and round, so it has fewer ways to show emotion. The bulb's is that it is the most generic of the four.
Three of those four have never been served to a visitor. The pint is the one on the homepage, because the logo is already a pint, and the other three exist in the repository as a bake-off that has not been thrown away yet. The comment above the list is the whole governance model: once one is chosen the others can go.
The grid is the interface
The reason four characters cost less than four times one character is that they are not four components. They are four compositions of the same parts file, 397 lines of it, and the shared contract is three lines of types:
export const MASCOT_MOODS = [ 'idle', 'happy', 'cheer', 'sad', 'nervous', 'shocked', 'sleepy', 'thinking', ] as const Eight moods, one grid, four characters. A screen asks for a mood, not for a drawing, which is why swapping the pint for the dog is a one-line change in a settings menu rather than a rewrite of every screen that shows a face.
The parts file exports the frame, the shadow, the body motion, an arm and a face. The frame is as dull as it should be:
<svg viewBox="0 0 120 120" width={size} height={size} role="img" aria-label={label} style={{ overflow: 'visible' }} > overflow: visible is load bearing. Several moods push a limb or a head of foam past the edge of the 120 box, and without that line the character gets a haircut.
An arm is three points and a mirror line
The most reusable thing in the file is the arm, which is a quadratic curve through three points, written once for the left side and flipped for the right:
/** A three-point arm: shoulder, bend, hand. */ export type ArmPose = readonly [ readonly [number, number], readonly [number, number], readonly [number, number], ] function armPath([[x0, y0], [x1, y1], [x2, y2]]: ArmPose, mirror: boolean, axis: number): string { const x = (v: number) => (mirror ? 2 * axis - v : v) return `M ${x(x0)} ${y0} Q ${x(x1)} ${y1} ${x(x2)} ${y2}` } So a mood only has to name three coordinates. The pint's table of arms is eight lines:
const ARM: Record<MascotMood, ArmPose> = { idle: [[31, 74], [21, 80], [22, 90]], happy: [[30, 70], [18, 68], [15, 59]], cheer: [[30, 68], [16, 58], [18, 44]], // ... and so on for the other five } The mirror axis is a per-character constant rather than the middle of the grid, because the glass was drawn with a handle on the right and sits centred on x = 56, not 60. That is the kind of detail that is invisible when it is right and unmistakable when it is wrong: one arm shorter than the other, on every screen.
Animating between poses is then just animating the d attribute with a spring, and a round hand is a circle animated to the third point:
const swing = { type: 'spring', stiffness: 260, damping: 18 } as const Every mood sets every transform
The body motion is a lookup table of whole-body transforms, and the comment above it explains the one rule that keeps it from drifting:
/** * Every mood sets every transform, so switching mood always lands the body back * where it should be. Looping values get their own repeating transition and * everything else settles once, which stops a static value from replaying on * every loop. */ In practice that means the idle entry writes out x, y, rotate, scaleX and scaleY even though only y does anything:
idle: { animate: { x: 0, y: [0, -2.5, 0], rotate: 0, scaleX: 1, scaleY: 1 }, transition: { default: settle, y: loop(2.6) }, }, If a mood omitted a property, the value left over from the previous mood would stay applied, and a character that had been nervous once would keep a slight tilt forever. The second half is the split transition: default settles over 350ms, and the looping property gets its own infinite one. Put a single repeat on the whole animation instead and the static values replay every cycle, which reads as a twitch.
The blink is the same idea at a smaller scale. One keyframe array, with times so the lid is down for about three per cent of the loop:
animate={{ scaleY: [1, 1, 0.1, 1] }} transition={{ duration: 4.2, times: [0, 0.93, 0.965, 1], repeat: Infinity }} A blink is 150ms out of 4.2 seconds. Written as an even four-keyframe animation it looks like the character is falling asleep.
One React detail worth stealing
Gradients inside an inline SVG need ids, ids have to be unique per instance, and React has a hook for that. It does not produce an id you can put in a url(#...) reference, because the value is allowed to contain characters that are not valid there:
/** An id that is safe inside url(#...) whatever characters React puts in useId. */ export function useSvgId(prefix: string): string { return `${prefix}-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}` } Five instances of the same character on one page, each with its own gradients, is exactly the case that breaks if you hardcode an id. Which brings us to the five.
What it costs, live
The marketing cameo is 36 lines and does three things: it provides the motion feature bundle, it respects the visitor's reduced-motion setting, and it hides the whole thing from screen readers because it is decoration:
<LazyMotion features={domAnimation}> <MotionConfig reducedMotion="user"> <div aria-hidden="true" className="pointer-events-none ..."> {line && <SpeechBubble text={line} tail="center" />} <Froth mood={mood} size={size} /> </div> </MotionConfig> </LazyMotion> Here is what the five instances actually weigh in the delivered HTML:
width bytes paths ellipses circles 120 2976 7 10 15 150 2960 7 10 15 140 3193 11 9 13 92 3183 11 9 13 104 3182 11 9 13 ------------------ total 15,494 bytes of 170,408 (9.1 per cent of the homepage) Nine per cent of our heaviest page is five drawings of a pint. For comparison, the same document has 48 <svg> elements in total, so the mascots are five of them and the remaining 43 are interface icons that cost a fraction as much each.
The two different shapes in that table are moods. The three 11-path instances have an extra arm pose and a fuller head of foam than the two 7-path ones. Nothing is sprited, cached or deduplicated: each one is a fresh tree of elements in the response, which is the price of inline SVG and the reason it works with no requests at all.
Two of the five are the marketing cameos, placed by hand on the page:
<FrothCameo mood="happy" size={92} line="Easy as one, two, three." className="hidden md:flex absolute right-16 lg:right-24 bottom-full -mb-2" /> Note hidden md:flex. That one is in the markup on every device and only visible from the medium breakpoint up, so a phone downloads a 3,183-byte drawing it will never render. The other three come from the product demos further down the page, which run the real screens with made-up data, and the real screens have a mascot in them.
The accessibility bit is more interesting than I expected
Across all 79 live pages there are exactly five role="img" elements, and they are these five. A census of our icons earlier this month found zero on the whole site, because interface icons are correctly marked aria-hidden and labelled by the text next to them.
So the mascot is the first thing on this site that claims to be an image to a screen reader. On the marketing page that claim does nothing, because the wrapper above it is aria-hidden="true" and hiding a subtree hides everything in it. The label is still right to be there: the same component is used on screens where the drawing is the only thing in its corner, and a component should not be correct only in the place it happens to be used today.
You can see the whole set from the outside:
curl -s https://pub-trivia.app/ | grep -o 'aria-label="Froth the pint"' | wc -l # 5 The writing rules got checked in too
The one-liners live in their own module, and the header of that file is a style guide for anybody adding to it:
/** * House rules for anything added here: friendly pub banter, never mean about a * player, no drinking jokes (the game runs at student unions, charity nights and * work events too), and no em dashes in anything a player reads. */ That first rule is the same one as the pint's watchOut field, and it is there because a mascot is the one part of an interface that is allowed to have a personality, which means it is also the one part that can be tactless on your behalf in front of a room of eighty people.
See it yourself
Open pub-trivia.app on a desktop width. The pint with the speech bubble is standing on the top edge of the "how it works" grid, and there is a second one, arms up, above "Ready for quiz night?" at the bottom. Scroll to the demo in between and you will find a third in the corner of the phone screen with an accessible name of "Poke Froth", which does what it says.
If you have reduced motion turned on in your operating system, all of it holds still, because of the MotionConfig line above. That was not true of this site a week ago, and it arrived with the cartoon rather than with the accessibility work, which is its own small lesson.
The free tier needs no card if you want to see the same components with a real quiz night behind them: pub-trivia.app.


