Design guide

Glass, done properly.

How the PRISM landing page was built โ€” the frosted-glass recipe, the 3D card tilt with a tracked specular glare, the drifting gradient mesh, and the pattern that makes every number on the page feel alive.

1 ยท The concept

PRISM is a fictional bank whose entire pitch is transparency as a physical property โ€” you can literally see through your money. The visual language had to embody that: every surface is glass, every number is legible, and the fee page is one line long.

The aesthetic school is glassmorphism, the frosted-translucency style Apple pushed mainstream with macOS Big Sur and visionOS. Done lazily it's a blur and a white border. Done properly it's a stack: a colorful, slowly moving world behind the glass (so the blur has something to chew on), a tinted layer for contrast, a 1px light catch on the top edge, and a specular highlight that responds to you. This page tries to do it properly.

The palette is deliberately narrow: deep navy, one violet, one cyan, one magenta, a whisper of gold on the card chip. The iridescence comes from gradients between those few values โ€” not from adding more colors.

2 ยท The techniques

The backdrop-filter glass recipe

Real glass is not just blur. The recipe here layers four things: a solid tint (so text always has AA contrast regardless of what drifts behind), a subtle white-to-transparent gradient (the sheet's own sheen), the blur+saturate backdrop filter (saturate is the secret โ€” it makes the world behind the glass glow instead of going grey), and an inset 1px highlight on the top edge where light would catch.

/* the whole glass system in one class */
.glass {
  background:
    linear-gradient(155deg, rgba(255,255,255,.10), rgba(255,255,255,.03) 55%),
    rgba(13,17,46,.55);            /* โ† solid tint = readable text */
  backdrop-filter: blur(22px) saturate(160%);
  border: 1px solid rgba(255,255,255,.14);
  box-shadow:
    0 24px 60px rgba(2,4,18,.5),   /* depth below */
    inset 0 1px 0 rgba(255,255,255,.16); /* light catch on top edge */
}

The tilt + glare math

The hero card maps pointer position to rotation: the cursor's offset from the card center, normalized to โˆ’0.5โ€ฆ0.5, becomes degrees of rotateX/rotateY. The raw values are never applied directly โ€” a requestAnimationFrame loop lerps current toward target each frame, which is what makes the card feel like it has mass. The glare is a radial gradient repositioned to the same normalized coordinates, blended with mix-blend-mode: screen so it reads as light, not paint.

// pointer โ†’ target rotation + glare position
const px = (e.clientX - rect.left) / rect.width  - .5;
const py = (e.clientY - rect.top)  / rect.height - .5;
targetRX = py * -13;  targetRY = px * 17;
glareX = (px + .5) * 100;  glareY = (py + .5) * 100;

// each frame: chase the target (the lerp is the luxury)
current += (target - current) * 0.09;
card.style.transform =
  `rotateX(${rx}deg) rotateY(${ry}deg)`;
glare.style.background =
  `radial-gradient(circle at ${gx}% ${gy}%,
     rgba(255,255,255,.34), transparent 60%)`;

The loop self-suspends: when every value is within 0.05 of its target it stops requesting frames, so an idle page costs nothing. On touch devices (no fine pointer) the card falls back to a slow CSS keyframe float; with prefers-reduced-motion it simply holds still. The chip, number, and logo sit at different translateZ depths inside a preserve-3d card, so the tilt has real parallax.

The gradient mesh background

The "animated mesh" is four huge circles โ€” up to 110vmax wide โ€” whose backgrounds are radial gradients fading to transparent. No filter: blur() at all: the gradient falloff is the blur, which costs the GPU nothing. Each blob drifts on its own 38โ€“60 second transform keyframe, alternating direction, so the composition never repeats visibly.

.blob-1 {
  width: 110vmax; height: 110vmax; border-radius: 50%;
  background: radial-gradient(circle,
    rgba(76,56,214,.55) 0%, transparent 68%); /* pre-blurred by falloff */
  animation: drift1 44s ease-in-out infinite alternate;
}
@keyframes drift1 {
  to { transform: translate(9vmax, 7vmax) scale(1.12); }
}

The count-up pattern

Every statistic on the page โ€” the balance, the APY projection, the trust numbers โ€” animates from zero when it scrolls into view. One IntersectionObserver watches the reveal sections; when one enters, it finds any [data-count] descendants and runs an eased rAF counter with toLocaleString formatting. The elements use font-variant-numeric: tabular-nums so digits don't jitter sideways while counting.

<b data-count="24806.52" data-decimals="2" data-prefix="$">$0.00</b>

const p = Math.min((now - t0) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);   // easeOutCubic
el.textContent = prefix +
  (target * eased).toLocaleString('en-US',
    { minimumFractionDigits: d, maximumFractionDigits: d });

The savings-facet calculator reuses the same idea in reverse: sliders update a target value, and the displayed number perpetually lerps toward it โ€” so dragging the slider makes the projection chase your input rather than snap.

Smaller tricks worth noticing

The sparkline draws itself using pathLength="100" + stroke-dasharray, so the dash math never depends on the path's real length. The feature-card glare is pure CSS custom properties (--mx/--my) set on pointermove and consumed by a ::after radial gradient. Film grain is an inline SVG feTurbulence data URI at 5% opacity โ€” it stops the big gradients from banding.

3 ยท How it was made

This site was designed and written by Claude (Fable 5) โ€” vanilla HTML, CSS, and JavaScript, by hand, in single files. No frameworks, no build step, no component library, no stock assets: every gradient, card, chip, and icon is code.

It's one of 25 deliberately different sites in the Fable Showcase, an experiment in how far one model can push web design across wildly different aesthetics. See the rest at fable-25-dhb.pages.dev.

4 ยท Steal this