tektonic
All Posts
Next.jsReactCanvasDesign SystemsFrontend

How We Built tektonic.company

A technical deep dive into the engineering behind our website. Procedural ASCII dithering, shallow water physics, OKLCH color science, device motion parallax, glass morphism, and a dual-theme system that transforms between monochrome and aurora.

Tektonic Labs / Engineering2026-03-2716 min read

What We Set Out to Build

We wanted a landing page that felt alive. Not a static hero with a gradient and a headline, but something that responded to you. Something that moved when you moved your phone. Something that turned a photograph of a mountain into a field of shifting characters. Something that simulated water on top of ASCII art. We also wanted it to be fast. No WebGL framework. No Three.js. No heavy animation library. Just canvas, CSS, and the platform APIs that modern browsers already ship. The entire site is a Next.js 16 application deployed on Vercel, and every visual effect you see is built from scratch. This post walks through how each piece works. The source code is available on GitHub.

The Stack

Next.js 16

Framework

with React 19 and Turbopack

TypeScript 5

Language

strict mode

Tailwind 4

Styling

with OKLCH color tokens

Inter + JetBrains Mono

Fonts

via next/font

Vercel

Deployment

edge network, static generation

0 deps

Bundle Overhead

for all visual effects

The ASCII Dither Effect

When you land on the site in dark mode, the hero section displays a mountain photograph rendered entirely as ASCII characters. Each character is chosen based on the brightness of the underlying pixel, and the entire field undulates with sine wave perturbations that respond to your device's gyroscope. Here is how it works, step by step. The Glyph Ramp. We define a 97-character string ordered by visual density, from lightest to heaviest:

Glyph Density Ramp

Characters are ordered from empty space (lightest) to @ (heaviest). The index into this string determines which character represents a given brightness level.

javascript
const GLYPH_RAMP =
  " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@";
Sampling. We load the source image (/kova-mountain.png, a 2430x1728 photograph) and draw it onto an offscreen canvas scaled to a grid. The grid dimensions are determined by the character cell size: each cell is fontSize * 0.6 pixels wide and fontSize pixels tall (we use fontSize 9, so each cell is 5.4px by 9px). On a 1920px wide viewport, that produces roughly 355 columns and 120 rows. We then call getImageData() to read the RGBA value of each cell. Wave Perturbation. Before mapping pixels to characters, we modulate the brightness of each cell using three overlapping sine waves. The wave directions are influenced by the device tilt (or mouse position on desktop), so tilting your phone shifts the wave pattern. The combination formula is:

Wave Combination

Three sine waves at different frequencies and phases combine to produce a single perturbation value. The tilt values (tx, ty) shift the wave directions in real time.

javascript
// Tilt-influenced wave directions
const dirX1 = 3.0 + tx * 1.5;
const dirY1 = 0.0 + ty * 1.5;
const dirX2 = 0.0 + tx * 0.8;
const dirY2 = 2.5 + ty * 0.8;
const dirXY3 = 4.0 + (tx + ty) * 0.6;

const w1 = Math.sin(nx * dirX1 + ny * dirY1 + wave1Phase) * 0.5 + 0.5;
const w2 = Math.sin(ny * dirY2 + wave2Phase + nx * (1.0 + dirX2)) * 0.5 + 0.5;
const w3 = Math.sin((nx + ny) * dirXY3 + wave3Phase) * 0.5 + 0.5;

const wave = w1 * 0.55 + w2 * 0.25 + w3 * 0.2;
Ripple Integration. When you tap the screen (or click on desktop), a ripple propagates outward from the touch point. For each active ripple, we compute the distance from the current cell to the ripple center, compare it against the expanding ring radius (age * 0.4), and add a sinusoidal perturbation if the cell falls within the ring width. The ripple fades over 2.5 seconds. Brightness to Character. After applying wave and ripple modulations, we convert the final RGB values to a single brightness value using the standard luminosity formula: 0.299 * R + 0.587 * G + 0.114 * B. This brightness maps to an index in the glyph ramp, and we draw the corresponding character at that grid position with fillText(). The color of each character preserves the tinted RGB from the wave modulation, so the ASCII field carries the warm amber tones of the original photograph. The entire render loop runs in requestAnimationFrame. On a 2024 MacBook Pro, it holds a steady 60fps at 42,000+ characters per frame.

The Fluid Overlay

On top of the ASCII dither (and the aurora photo in light mode), we render a real-time shallow water simulation. This is the subtle liquid caustic effect you see when you tilt your device or tap the screen. The Algorithm. We maintain two Float32Array buffers representing wave height at each cell in a grid. The grid cell size starts at 5px and adapts based on frame time. On each frame, we propagate waves using a discrete approximation of the shallow water equation:

Wave Propagation (Shallow Water Equation)

For each cell, the new height is computed from the average of its four neighbors minus the previous height, multiplied by a damping factor. Tilt values add directional current.

javascript
for (let y = 1; y < gridH - 1; y++) {
  for (let x = 1; x < gridW - 1; x++) {
    const idx = y * gridW + x;
    const avg = (
      buf1[(y - 1) * gridW + x] +
      buf1[(y + 1) * gridW + x] +
      buf1[y * gridW + (x - 1)] +
      buf1[y * gridW + (x + 1)]
    ) / 2;
    buf2[idx] = (avg - buf2[idx]) * 0.985; // damping
    // Tilt-driven current
    buf2[idx] += tx * (buf1[y * gridW + x + 1] - buf1[y * gridW + x - 1]) * 0.02;
    buf2[idx] += ty * (buf1[(y + 1) * gridW + x] - buf1[(y - 1) * gridW + x]) * 0.02;
  }
}
Caustic Rendering. After propagation, we visualize the wave heights as colored cells on a canvas. Positive heights render as warm amber in dark mode and purple in aurora mode. Steep height gradients between adjacent cells produce bright white "caustic" lines, mimicking the light patterns you see at the bottom of a swimming pool. Ambient Activity. To keep the surface alive even without user interaction, we seed five initial splash points on mount and add periodic gentle drips every 2 to 3 seconds at random locations. User taps generate stronger splashes (strength 80 vs 15 to 40 for ambient drips). Adaptive Resolution. Every 60 frames, we measure average frame time. If it exceeds 18ms, we increase the cell size (reducing grid resolution). If it drops below 12ms, we decrease it. This keeps the simulation smooth on everything from a phone to a high-refresh desktop monitor.

Device Motion and Parallax

Both the ASCII dither and the fluid overlay respond to device orientation. On mobile, we read the gyroscope via the DeviceOrientation API. On iOS 13+, this requires an explicit permission request. On desktop, we fall back to mouse position. The raw sensor data is noisy, so we smooth it with linear interpolation at different rates: 0.08 for the gyroscope (faster response) and 0.05 for the mouse (smoother tracking). All updates happen through refs, not React state, so the smoothing loop never triggers a re-render. We also respect prefers-reduced-motion. If the user has motion sensitivity enabled in their OS settings, all parallax and animation effects are disabled. We attach a live listener so changes take effect immediately without a page reload. Taps create ripples. We normalize the touch coordinates to a 0 to 1 range, store them in a shared ref, and both the dither and fluid overlay read from the same ripple array. Maximum 10 concurrent ripples with a 2.5-second lifetime. On mobile, we fire a subtle 8ms haptic vibration on each tap via navigator.vibrate().

The OKLCH Color System

Every color in the design system is defined in OKLCH, a perceptually uniform color space. Unlike HSL, where a 10-degree hue shift at high saturation looks dramatically different from the same shift at low saturation, OKLCH distributes perceptual differences evenly. This means our dark and aurora themes produce consistent contrast ratios across all surface levels without manual tuning. The dark theme is achromatic. Every token uses chroma 0, so the palette is pure grayscale from near-black (oklch(0.145 0 0)) to near-white (oklch(0.985 0 0)). The aurora theme introduces hue 270 (purple) across all surfaces, with chroma values between 0.04 and 0.15. The primary color shifts to oklch(0.75 0.15 300), a vibrant purple. The gold accent (oklch(0.82 0.12 85)) anchors the gradient text utility in dark mode. In aurora mode, it shifts to the same purple primary. Both themes use oklch-based borders with percentage-based alpha: oklch(1 0 0 / 10%) for dark, oklch(0.75 0.15 300 / 12%) for aurora. Theme transitions are handled by CSS custom properties with a 600ms ease transition on both html and body background-color, so switching themes produces a smooth cross-fade rather than a hard cut.

Glass Morphism

We use two levels of glass effect throughout the site. The lighter glass-card is used for content containers. The heavier liquid-glass is used for interactive elements like buttons and dropdowns.

Glass Card (Content Surfaces)

Subtle translucency with a thin border. The aurora variant shifts to a purple tint.

css
.glass-card {
  background: rgba(255, 255, 255, 0.03);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.08);
}
.aurora .glass-card {
  background: rgba(139, 92, 246, 0.04);
  border: 1px solid rgba(139, 92, 246, 0.1);
}

Liquid Glass (Interactive Elements)

Heavier blur, higher saturation, inset highlight, and drop shadow. Inspired by Apple's visionOS material.

css
.liquid-glass {
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  border: 1px solid rgba(255, 255, 255, 0.15);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.1),
    0 2px 8px rgba(0, 0, 0, 0.15);
}

Theme Switching

The site ships with two themes: dark (monochrome, ASCII dither hero) and aurora (purple-tinted, clean mountain photo hero). Switching between them is more than a color swap. It changes the hero rendering mode, the particle colors, the glass tints, the gradient directions, and the text shadow colors. Under the hood, we use next-themes with class-based attribute application. The theme class (.dark or .aurora) is set on the <html> element, and all theme-dependent styles use the Tailwind aurora: custom variant we defined in globals.css with @custom-variant aurora (&:is(.aurora *));. The Intro Flicker. On first visit, the site runs a choreographed theme flicker sequence. It starts in dark mode, switches to aurora at 400ms, back to dark at 1200ms, aurora again at 2000ms, and settles on dark at 2800ms. This creates the impression of the site "booting up." A localStorage flag prevents it from running on subsequent visits. Hero Toggle. On the hero section, scrolling (wheel event) or swiping (touch event with >30px delta) toggles the theme with an 800ms cooldown. This lets visitors discover the aurora mode naturally by scrolling down on the hero.

Particle Systems

Stars. We render 50 stars using pure CSS. Each star's position, size, color, twinkle speed, and drift direction are deterministically computed from its index using a hash function. No randomness, so the star field is identical on every render. Stars twinkle between 20% and 100% opacity over 3 to 7 second cycles and drift by a few pixels in each direction. In dark mode they are warm yellow-whites. In aurora mode they shift to blue-purples. Meteors. Four concurrent meteors streak diagonally from the top-right to bottom-left at a 35 degree angle. Each meteor is a single div with a linear gradient background (color to transparent) and a CSS keyframe animation. The animation ramps opacity from 0 to peak over 5% of the duration, holds for 55%, then fades while translating -50vw horizontally and 50vh vertically. Trail lengths vary from 80px to 200px. Durations are staggered between 0.8s and 1.85s to avoid synchronized movement.

Performance

Dynamic Imports. Both GlyphDither and FluidOverlay are loaded with dynamic(() => import(...), { ssr: false }). They are client-only components with no server-side rendering. This keeps the initial HTML payload small and avoids hydration mismatches from canvas state. RAF Pausing. Both canvas render loops check document.hidden on every frame and skip all computation when the tab is not visible. This drops CPU usage to zero when the user switches tabs. Ref-Based Updates. The device motion hook updates shared refs, not React state. The dither and fluid components read these refs inside their render loops. This means device orientation changes never trigger React re-renders. Canvas Over DOM. All visual effects use <canvas> elements with direct 2D context drawing. No DOM nodes are created or destroyed during animation. The stars and meteors are the only exceptions, and they use pure CSS animations with pointer-events: none so the browser can composite them on the GPU layer without triggering layout.

Source Code

The full source code for tektonic.company is available on GitHub. The key files:

src/components/glyph-dither.tsx – ASCII dithering engine
src/components/fluid-overlay.tsx – Shallow water simulation
src/hooks/use-device-motion.ts – Gyroscope, mouse, and ripple system
src/app/globals.css – OKLCH design tokens, glass effects, keyframes
src/components/hero.tsx – Hero section compositing all effects
src/components/intro-animation.tsx – First-visit theme flicker