Kip documentation
A five-page HTML template with an interactive 3D mascot. No build step, no framework, no dependencies to install.
Open the template
index.html by double-clicking it.
Browsers block ES modules and local model files on the file://
protocol, so the 3D mascot will not start and you will see a static image
instead. Everything else works, but you will not see the template as
intended. Run a local server:
cd kip
# any one of these
python3 -m http.server 8000
npx serve .
php -S localhost:8000
Then open http://localhost:8000. Once the site is on a real host this stops mattering, it only affects opening files from disk.
File structure
kip/
├── index.html Product / home
├── features.html Feature detail + spec table
├── pricing.html Plans, billing toggle, FAQ
├── about.html Studio, timeline, team
├── contact.html Contact form
├── 404.html Not-found page
├── _headers Security headers (Cloudflare Pages / Netlify)
├── robots.txt, sitemap.xml
├── LICENSE.txt
└── assets/
├── css/style.css Everything. Design tokens at the top.
├── css/print.css Print rules only, loaded with media="print"
├── js/main.js Theme, nav, form, counters, liquid surfaces
├── js/kip-scene.js The 3D mascot
├── models/kip.glb The model (CC0, see LICENSE.txt)
├── img/ Static mascot fallback
└── svg/mark.svg Logo mark
Colour and theming
Every colour is a custom property at the top of assets/css/style.css. Light lives under :root, dark under :root[data-theme="dark"]. The two are written independently. Dark is not a filter applied to light, so change them separately.
:root {
--ground: #f1f3f7; /* page background */
--ground-2: #e8ebf2; /* recessed surfaces */
--ink: #1d1d1f; /* body text */
--ink-muted: #4b4b52; /* secondary text */
--ink-dim: #6b6b73; /* labels, captions */
--accent: #1d1d1f; /* buttons: the ink itself */
--link: #0057d9; /* links and focus rings */
}
--ground to #fff the panels will disappear.The theme toggle writes to localStorage under the key kip-theme. To ship a single-theme site, delete the toggle button from the nav and hard-code data-theme on the <html> element.
Typography
| Role | Face | Where |
|---|---|---|
| Display | Hanken Grotesk | Headings |
| Body | Albert Sans | Paragraphs, UI |
| Mono | Fragment Mono | Eyebrows, labels, figures |
All three are Google Fonts under the Open Font License. Swap them in the <link> in each page's <head> and in the --font-* tokens. To self-host, download the files, drop them in assets/fonts/, replace the link with @font-face rules, and remove the two preconnect lines.
Body text is 17px rather than 16px, and display sizes carry negative letter-spacing that tightens as they grow. If you change the display face, re-check the tracking, because it is size-specific and a single value will be wrong somewhere.
The glass system
Four tiers of translucency. A deeper tier means more blur, more fill and a brighter edge, so larger surfaces read as physically thicker glass.
| Class | Used for |
|---|---|
.glass--1 | Faintest, large background panels |
.glass / .glass--2 | Default, cards, nav |
.glass--3 | Elevated, mobile sheet, featured plan |
.glass--4 | Heaviest, closing call-to-action band |
Panels carrying the .liquid class also get a specular highlight that follows the pointer, plus a rim that catches the same light. In light mode this is a travelling shadow, not a glow. A white highlight on a white panel is invisible, and real glass over a pale surface darkens where it is thickest. In dark mode it inverts to a glow. main.js adds the class automatically; edit the liquidSel selector there to change which elements get it.
The drifting background washes exist to give the blur something to work on. Without them, frosted glass over a flat colour looks like flat colour. They are the three .field span elements and they animate on 34–42 second cycles.
The 3D mascot
Configured per page with data attributes, so it can stand somewhere different on every page rather than repeating one shot five times.
<div class="hero-stage" data-kip-stage
data-kip-crop="chest"
data-kip-turn="-30"
data-kip-orbit="-42"
data-kip-look="1.15"
data-kip-greet="Hello">
<img class="kip-fallback" src="assets/img/kip-still.png"
width="900" height="900" alt="...">
</div>
| Attribute | Values | Effect |
|---|---|---|
data-kip-crop | hips, chest, full | How tight the shot is framed |
data-kip-turn | degrees | Which way he faces |
data-kip-orbit | degrees | Where the camera stands around him |
data-kip-look | 0 – 1.5 | How strongly he tracks the cursor |
data-kip-greet | clip name | Played once on arrival |
Animation clips
The model contains Idle, Hello, Yes, No, Dance, Jump, Pickup, Walk, Run, and several combat clips.
Only Hello, Yes and Dance are wired up as click reactions. The others were tested and rejected: Pickup bends him until his head leaves the frame, Jump throws his legs past the bottom fade, and No is indistinguishable from Yes at this crop. Edit the REACTIONS array in kip-scene.js to change the set.
How the look-at works
The cursor rotation is applied to Torso, Chest, Neck and Head in increasing amounts, so the turn travels up the spine instead of the head snapping around on one joint. It is layered on top of whatever clip is playing, and it fades rather than switching: during an animation it eases down to about a third instead of to zero, so he keeps watching you while he waves.
POSED array and the nudge() calls. Bones the current clip does not keyframe must be restored to their rest pose each frame, or hand-applied rotations accumulate and the model slowly spins.Removing the 3D
Delete the <script type="importmap"> block and the kip-scene.js tag from each page. The static image remains, nothing else changes, and you can delete assets/models/ to save 2.9 MB.
Contact form
The form validates on blur and on submit, shows inline errors, a loading state and a success message, and then deliberately sends nothing. Wire it to your own endpoint in main.js, in the submit handler:
// replace the setTimeout block with a real request
fetch("https://your-endpoint.example/submit", {
method: "POST",
body: new FormData(form)
})
.then(function () {
submitBtn.removeAttribute("data-loading");
successBox.setAttribute("data-shown", "");
form.reset();
})
.catch(function () {
submitBtn.removeAttribute("data-loading");
// show your own error state here
});
Formspree, Basin, Getform and Netlify Forms all work with no server. A hidden utm field captures campaign parameters from the query string automatically and is submitted with the rest.
connect-src in _headers, or the Content Security Policy will block the request.Billing toggle
Prices live in data attributes on the markup, so the real monthly figures are visible with JavaScript disabled:
<span data-price data-monthly="9" data-annual="7.50">$9</span>
Counters
Same principle. The final number is written in the HTML; the script only animates a count up to it. With JavaScript blocked the real figure stays on the page, it never shows a zero.
<span data-count-to="41200" data-count-suffix="+">41,200+</span>
Deploying
Upload the folder. There is nothing to compile. It works on Cloudflare Pages, Netlify, Vercel, GitHub Pages, S3 or any shared host.
_headers sets a strict Content Security Policy plus HSTS, nosniff, a referrer policy and frame denial. Cloudflare Pages and Netlify read it as-is. On Apache or Nginx, translate it to your own config. GitHub Pages cannot set custom headers at all, so the file is silently ignored there.
Update robots.txt and sitemap.xml with your real domain, and the og:url and canonical tags in each page's head.
Performance
The model is about 2.9 MB and loads after first paint, with the static image showing until it is ready, so it does not block rendering or Largest Contentful Paint. Rendering pauses automatically when the mascot scrolls out of view or the tab is hidden.
To cut the weight further, resize the texture inside the .glb. Be aware the material is unlit, meaning the shading is painted into the texture rather than computed from lights, so reducing texture resolution reduces the visible quality directly.
Accessibility
- Skip link, visible focus rings, logical heading order on every page.
- Form fields have real labels; errors are linked to their inputs and announced.
- Touch targets meet 44px; body text is never below 16px.
- Both themes meet WCAG AA for text contrast.
prefers-reduced-motiondisables the drifting washes, the reveal animations and the 3D entirely, the static image is shown instead.prefers-reduced-transparencyturns the glass solid;prefers-contrast: morestrengthens every border.
Third-party assets
| Asset | Licence | Bundled? |
|---|---|---|
| Mech model "Stan" by Quaternius | CC0 (public domain) | Yes |
| Three.js r169 | MIT | No, CDN |
| Hanken Grotesk, Albert Sans, Fragment Mono | SIL OFL 1.1 | No, Google Fonts |
No stock photography, no third-party trademarks, nothing hot-linked. Full detail in LICENSE.txt.
Troubleshooting
The mascot does not appear
You are almost certainly opening the file directly from disk. See Open the template. Otherwise: check the browser console for a blocked request to cdn.jsdelivr.net, confirm WebGL is enabled, and check whether your system has "reduce motion" turned on, that disables the 3D by design.
The glass panels look flat
Your --ground is probably too close to white. Frosted white panels need a darker surface behind them. See Colour and theming.
The mascot spins slowly on its own
You changed the model or the bone names without updating the POSED array. Bones that the playing clip does not animate must be reset to their rest pose each frame.
Fonts do not load behind a firewall
Self-host them, see Typography. The layout is unaffected; only the faces change.