What you need before you write any markup
You need two files and one decision. The files are a transparent WebM of the animation and a poster image, a PNG or WebP of a single good frame with a transparent background. The decision is what Safari and iOS visitors should see, because they cannot play transparent WebM today (the formats guide explains why in detail). Your three options, in order of effort:
- Show the poster. Safari users see a still character. Zero extra work and often perfectly acceptable for a decorative placement.
- Serve the GIF. Everybody sees motion; Safari users get a heavier, slightly rougher file. Good when the movement is the point.
- Serve an HEVC-with-alpha MOV. Best result on Apple devices; requires converting the ProRes MOV on a Mac.
If you are exporting from the studio, the WebM and GIF are already prepared, and the approved still design works as the poster. Keep the WebM under roughly 2 MB for anything that appears near the top of the page; for a six-second, 720-pixel character that is the normal range, and displaying it at 200 to 400 pixels means you can afford a higher compression setting than you think.
The correct <video> element, line by line
<video
class="mascot"
autoplay muted loop playsinline
preload="metadata"
poster="/media/fox-poster.webp"
width="720" height="720"
aria-hidden="true"
>
<source src="/media/fox-hevc.mov" type='video/mp4; codecs="hvc1"'>
<source src="/media/fox.webm" type="video/webm">
</video>autoplay muted loop playsinline: all four, always. Browsers refuse to autoplay anything with sound, somutedis what makesautoplaywork at all.playsinlinestops iOS from launching a full-screen player. A mascot withoutloopfreezes on its last frame after a few seconds.preload="metadata": the browser fetches headers and the first frame, not the whole file, until playback is actually requested. Autoplay will still start promptly once the element is on screen. Usepreload="none"for anything below the fold and start it yourself (next section).poster: what paints immediately, what Safari shows if it cannot decode any source, and what search engines and screenshot tools capture. Never skip it.widthandheight: intrinsic size so the browser reserves space before the video loads. Without them the layout jumps when the first frame arrives, which shows up as Cumulative Layout Shift.- Source order: the HEVC source goes first because Safari picks the first source it can play; Chrome and Firefox skip it because they do not recognise
hvc1and move on to the WebM. Reverse the order and Safari may pick the WebM and show black. If you are not serving HEVC, delete that line rather than pointing it at the ProRes file. aria-hidden="true": the character is decorative. If it carries meaning (for example it is the only illustration of an error), droparia-hiddenand put the meaning in adjacent text, not in analtthe video element does not have.
To fall back to the GIF instead of HEVC for Safari, use a picture-style feature test in a couple of lines of script:
<script>
const v = document.createElement('video');
const webmAlpha = v.canPlayType('video/webm; codecs="vp9"') !== '';
if (!webmAlpha) {
document.querySelectorAll('video.mascot').forEach(video => {
const img = new Image();
img.src = video.dataset.gif; // data-gif="/media/fox.gif"
img.width = video.width; img.height = video.height; img.alt = '';
video.replaceWith(img);
});
}
</script>canPlayType cannot tell you about alpha specifically, but the browsers that report VP9 support are exactly the browsers that decode its alpha plane, so the proxy is reliable today. When Safari ships VP9 alpha decoding it will start returning a positive answer and automatically get the better file.
Sizing, positioning, and layering with CSS
A transparent video is just a box with see-through pixels, so it obeys every layout rule an image does. A few patterns cover nearly every mascot placement:
/* Inline in a hero, scales with the column */
.mascot { display: block; width: min(38vw, 360px); height: auto; }
/* Peeking over the edge of a card */
.card { position: relative; overflow: visible; }
.card .mascot { position: absolute; right: -24px; bottom: -8px; width: 140px; pointer-events: none; }
/* Sitting on the page background, behind content */
.mascot.backdrop { position: absolute; inset: auto 4% 0 auto; width: 260px; z-index: 0; opacity: .92; }
.hero-content { position: relative; z-index: 1; }Three practical notes. Use pointer-events: none when the character overlaps interactive elements, or it will silently swallow clicks. Do not apply object-fit: cover; the exports are square with the character centred, and cropping usually cuts off a hand mid-wave. Set a tinted background only on the container, never the video: the transparency does the compositing, and a background on the <video> element itself shows through the transparent pixels and defeats the purpose.
For a subtle shadow under the character, let the video carry it if the render includes one, or add filter: drop-shadow(0 18px 24px rgb(0 0 0 / .18)) on the element. drop-shadow follows the alpha silhouette; box-shadow would draw a rectangle.
Keeping Core Web Vitals green
Autoplaying hero video is one of the most common causes of a failed Largest Contentful Paint. The fix is not to avoid video; it is to make sure the video is never the thing the page is waiting for.
- Let the poster be the LCP element. Serve it as an optimised WebP, sized to its display width, and add
fetchpriority="high"via a<link rel="preload" as="image">if it is the largest thing above the fold. Chrome treats a video’s poster as its LCP candidate, so a fast poster means a fast LCP regardless of the video. - Lazy-start anything below the fold. Give it
preload="none", omitautoplay, and callplay()from anIntersectionObserver. Pause it again when it scrolls away; a page with four looping characters decoding off-screen is a warm laptop for no reason. - One file per breakpoint is unnecessary. Unlike a hero photo, the character is small on every device; a single 720-pixel WebM around 1.5 MB is fine for mobile as long as it is not blocking the first paint.
- Cache it forever. Fingerprint the filename and send
Cache-Control: public, max-age=31536000, immutable. Repeat visitors then pay nothing. - Serve
Accept-Ranges. Every CDN does; some hand-rolled static servers do not, and without byte-range support Safari and iOS refuse to play video at all.
const io = new IntersectionObserver(entries => {
for (const { target, isIntersecting } of entries) {
if (isIntersecting) { target.play().catch(() => {}); } else { target.pause(); }
}
}, { rootMargin: '200px 0px' });
document.querySelectorAll('video.mascot[data-lazy]').forEach(v => io.observe(v));The catch is deliberate. play() returns a promise that rejects when autoplay policy blocks it, and an unhandled rejection is noise in your error tracking for a situation you have already designed for (the poster shows).
Reduced motion and the five-second rule
Two accessibility requirements apply to a looping character, and they are different from each other. The first is the operating-system preference: some visitors have asked for less motion, and a bouncing mascot is exactly what they meant. The second is WCAG success criterion 2.2.2, Pause, Stop, Hide: any moving content that starts automatically and lasts longer than five seconds needs a way to stop it, for every visitor, whatever their settings.
Handle the preference in CSS and script together, because CSS alone cannot stop a video:
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
function applyMotion() {
document.querySelectorAll('video.mascot').forEach(v => {
if (reduce.matches) { v.pause(); v.removeAttribute('autoplay'); } else { v.play().catch(() => {}); }
});
}
reduce.addEventListener('change', applyMotion);
applyMotion();For the pause control, a small button near the character is enough: it toggles play() and pause(), carries an aria-pressed state, and remembers the choice in localStorage so the character does not restart on the next page. A site-wide “pause animations” toggle in the footer also satisfies the criterion and is easier to keep consistent if several pages have characters.
A quieter alternative that avoids the whole question: loop the Idle motion rather than Dance. A character that breathes and blinks reads as alive without demanding attention, and many teams find it works better on pages people actually read. The app mascot guide goes into which motion suits which moment.
React and Next.js component
A reusable component that does everything above: lazy start, reduced motion, GIF fallback for browsers without VP9 alpha, and a stable layout box.
'use client';
import { useEffect, useRef, useState } from 'react';
type Props = { webm: string; gif: string; poster: string; size?: number; label?: string; hevc?: string };
export function Mascot({ webm, gif, poster, hevc, size = 280, label }: Props) {
const ref = useRef<HTMLVideoElement>(null);
const [useGif, setUseGif] = useState(false);
useEffect(() => {
const probe = document.createElement('video');
if (probe.canPlayType('video/webm; codecs="vp9"') === '' && !hevc) setUseGif(true);
}, [hevc]);
useEffect(() => {
const video = ref.current;
if (!video) return;
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !reduce.matches) video.play().catch(() => {});
else video.pause();
}, { rootMargin: '160px 0px' });
io.observe(video);
return () => io.disconnect();
}, [useGif]);
if (useGif) return <img src={gif} width={size} height={size} alt={label ?? ''} loading="lazy" />;
return (
<video ref={ref} muted loop playsInline preload="none" poster={poster}
width={size} height={size} aria-hidden={label ? undefined : true} aria-label={label}>
{hevc && <source src={hevc} type='video/mp4; codecs="hvc1"' />}
<source src={webm} type="video/webm" />
</video>
);
}If an AI coding assistant is doing this work for you, it can also generate the clip and save it beside the component in the same session; the MCP guide shows that workflow. In Next.js, put the media files in public/ or on your asset host and pass plain URLs; do not run video through next/image. Because the component is a client component, render it inside a server component page as usual; the poster is in the initial HTML, so there is no flash of empty space during hydration. In React 19, muted is applied as a property correctly; in older React versions the attribute was sometimes dropped from server-rendered markup, which broke autoplay, so if you are on React 17 or 18 set video.muted = true in the effect as well.
Webflow, Framer, WordPress, Squarespace, Shopify
No-code and CMS platforms all support the pattern, but each hides the <video> element behind a different door.
Webflow
The built-in Background Video element re-encodes uploads to an opaque MP4, which destroys transparency. Instead, host the WebM (Webflow’s asset manager accepts it, or use any CDN) and drop an Embed element containing the <video> markup from earlier. Give it a class in the embed and style the size in Webflow’s designer by targeting the parent div.
Framer
Framer’s Video component keeps WebM as-is and exposes autoplay, loop, and muted toggles; upload the WebM and set the poster in the component’s properties. Framer does not currently let you add a second source, so for an HEVC fallback use a Code Component with the React version above. Transparency shows in the published site but not always in the editor canvas.
WordPress
The core Video block accepts WebM uploads (enable the MIME type if your host blocks it) and exposes autoplay, loop, muted, and inline playback as block settings; the poster is set in the block sidebar. Note that the block honours reduced motion only with a plugin or a small snippet, so add the script from the accessibility section in a Custom HTML block or your theme. If you use a page builder like Elementor or Bricks, their video widgets behave the same way; avoid the “background video” variants for the same reason as Webflow.
Squarespace and Shopify
Both accept a Code block (Squarespace) or a Custom Liquid section (Shopify) with raw HTML. Upload the WebM to Files (Squarespace) or Content › Files (Shopify), copy the CDN URL, and paste the video markup with that URL. Shopify’s files CDN serves correct MIME types and byte ranges, so iOS playback works out of the box.
Notion, Carrd, and other tools without raw HTML
Use the GIF. It is the trade-off these tools make on your behalf, and a 480-pixel GIF of a short loop is still a perfectly good mascot on a simple page.
Where a website mascot actually helps
The temptation is to put the character in the hero and stop. The hero is fine, but the placements that earn their keep are usually smaller and later:
- The 404 page. The one page every visitor arrives at annoyed. A character shrugging or looking around turns a dead end into a small joke, and it is the placement designers most often wish they had budgeted for.
- Beside the primary form. Sign-up, newsletter, contact. A character glancing toward the fields draws the eye without a single arrow graphic.
- Pricing page reactions. A wave on the recommended plan, a cheer after the toggle flips to annual. Keep it to one motion; two characters competing on a pricing page is a circus.
- Documentation and changelogs. A GIF at the top of a release note makes “what’s new” feel like an event rather than a list.
- Loading and success states in web apps. These are covered from the product-design side in the app mascot guide; technically they are the same
<video>withloopremoved for one-shot celebrations.
A single rule keeps all of this tasteful: one moving thing per viewport. If the character is moving, nothing else should be. If you have another animation on screen, pause the character or let it idle.
Launch checklist
- WebM under ~2 MB, poster as optimised WebP, both fingerprinted and cached immutably.
autoplay muted loop playsinlinepresent;width/heightset;preloadchosen deliberately.- Safari strategy decided: poster, GIF fallback, or HEVC source first.
- Tested on a real iPhone, not only the simulator, including with Low Power Mode on (it blocks autoplay; the poster should look intentional).
- Reduced motion respected; a pause control exists for loops longer than five seconds.
pointer-events: nonewherever the character overlaps buttons or links.- Lighthouse LCP element is the poster or the headline, never the video.
- No other animation competes with the character in the same viewport.
If you do not have the character yet, the studio produces the WebM, GIF, and MOV together from a single approved design; the how it works page shows the four steps, and the use cases page has more placement ideas by industry.
