Kip documentation

A five-page HTML template with an interactive 3D mascot. No build step, no framework, no dependencies to install.

Open the template

Do not open 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
Print rules are kept in their own file on purpose. Merged into the screen stylesheet, they make contrast-checking tools measure every colour against paper white and report dozens of false failures.

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      */
}
The ground is deliberately not pure white. The glass panels are frosted white, and they can only read as raised surfaces if what is behind them is tonally lower. If you set --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

RoleFaceWhere
DisplayHanken GroteskHeadings
BodyAlbert SansParagraphs, UI
MonoFragment MonoEyebrows, 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.

ClassUsed for
.glass--1Faintest, large background panels
.glass / .glass--2Default, cards, nav
.glass--3Elevated, mobile sheet, featured plan
.glass--4Heaviest, 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>
AttributeValuesEffect
data-kip-crophips, chest, fullHow tight the shot is framed
data-kip-turndegreesWhich way he faces
data-kip-orbitdegreesWhere the camera stands around him
data-kip-look0 – 1.5How strongly he tracks the cursor
data-kip-greetclip namePlayed 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.

If you replace the model, keep the bone names or update the 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.

If you point the form at a third-party service, add its domain to 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

Third-party assets

AssetLicenceBundled?
Mech model "Stan" by QuaterniusCC0 (public domain)Yes
Three.js r169MITNo, CDN
Hanken Grotesk, Albert Sans, Fragment MonoSIL OFL 1.1No, 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.