Overview#
Prism is a marketing website for an AI startup, built for Prism, a fictional research assistant made by Prism Labs (Toronto and London). The product answers questions from a team's docs, tickets and the web with citations, in an app and through an API. The site has a home page with a streaming playground demo, a product page, API docs, pricing with a cost calculator, a changelog, a company and careers page and a sales form. This is the Next.js version (Next.js 16 App Router, React 19, TypeScript, Tailwind CSS v4), version 1.0.0, ported page for page from the HTML template. The design is dark-first: near-black surfaces, a hairline grid, film grain and spectrum gradients taken from light passing through a prism. It exports to static files. Live demo: https://freeframe.dev/templates/prism
The site is a front end only. The playground streams pre-written answers, and the forms don't send anything until you connect your own services (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ (npx serve out) |
pnpm lint |
ESLint |
pnpm typecheck |
next typegen and TypeScript 7 tsc --noEmit |
pnpm build:demo |
Build for freeframe.dev under /demos/nextjs/prism into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero, streaming playground, logo strip, feature grid, how it works, benchmark chart, testimonials, call to action |
/product |
src/app/product/page.tsx |
Search, write, briefs and analyse sections with UI mock-ups, integrations grid (#integrations), security badges and data retention table (#security), model cards and latency chart (#models) |
/developers |
src/app/developers/page.tsx |
Example response, three-step quickstart with code tabs (#quickstart), endpoint list (#endpoints), SDK cards with copy buttons (#sdks), rate limits table (#rate-limits) |
/pricing |
src/app/pricing/page.tsx |
Monthly / annual switch, three plans, API price table, cost calculator (#calculator), FAQ accordion (#faq) |
/changelog |
src/app/changelog/page.tsx |
Dated releases on a timeline, topic filter with counts (/changelog?tag=api) |
/about |
src/app/about/page.tsx |
Story, figures, values, team, open roles with a team filter (/about?team=research#careers) |
/contact |
src/app/contact/page.tsx |
What happens next, offices, sales form (/contact?topic=security preselects the topic) |
| 404 | src/app/not-found.tsx |
Prism illustration and links back to the site (exported as 404.html, noindex) |
Project structure#
src/
app/
layout.tsx <html>, skip link, header, <main>, footer, live region, reveals, back to top
globals.css Tailwind import, @theme tokens, component classes (buttons, cards, code, playground...)
fonts.ts Geist, Geist Mono and Instrument Serif with next/font/local
page.tsx home page; one folder per other route with its page.tsx
not-found.tsx 404 page
icon.svg favicon (the Prism mark)
opengraph-image.png social sharing image (1200×630)
components/
site-header.tsx sticky header, current page, mobile menu (client)
site-footer.tsx footer columns, status pill, sign-up form
playground.tsx streaming playground (client)
benchmarks.tsx chart legend and table; benchmark-chart.tsx draws the SVG (client)
logo-strip.tsx customer wordmarks in a looping marquee
billing.tsx monthly / annual switch and prices that follow it (client)
cost-calculator.tsx seats + tokens estimate (client)
accordion.tsx FAQ accordion (client)
code-tabs.tsx language tabs for code samples (client)
copy-button.tsx copy to clipboard (client)
filter.tsx topic filters for releases and roles (client)
contact-form.tsx sales form (client); newsletter-form.tsx is the footer form
use-validation.tsx inline validation hook shared by both forms
success-box.tsx the message that replaces a sent form
announcer.tsx one polite live region for short announcements
reveal-observer.tsx scroll reveals; back-to-top.tsx; logo.tsx; check-icon.tsx; year.tsx
data/
nav.ts header and footer links
playground.ts playground examples, fallback answer and models
benchmarks.ts benchmark models and scores
pricing.tsx calculator settings, plans, API prices, FAQ
developers.tsx code samples, endpoints, SDKs, rate limits
changelog.ts releases and topics
careers.ts open roles and teams
lib/
validation.ts form rules (required, email, work email, min length, groups)
code-lang.ts the remembered code language
storage.ts safe localStorage and URL parameter helpers
announce.ts, copy.ts, metadata.ts, asset.ts, rich-text.tsx
fonts/ woff2 files and their OFL licenses
public/img/
prism-beam.svg prism and light-beam illustration (CTA panels and 404)
og-image.svg source of the social imageCustomising#
Brand and colours#
The design tokens live in the @theme block of src/app/globals.css. Every utility such as bg-ink-900,
text-muted or text-brand-300 comes from them.
| Token | Default | Used for |
|---|---|---|
--color-ink-950 |
#05060a |
Page background |
--color-ink-900, --color-ink-850, --color-ink-800 |
#0a0c12, #0f1219, #151924 |
Cards, inputs, raised panels |
--color-fg |
#eef0f6 |
Headings, body text, primary buttons |
--color-muted |
#a6acbe |
Secondary text |
--color-subtle |
#8b92a7 |
Captions and meta text (still AA on every surface) |
--color-brand-200 … --color-brand-700 |
aqua scale | Links, focus rings, highlights, citation chips |
--color-ray-rose, -amber, -lemon, -mint, -sky, -violet |
the spectrum | Logo, gradients, chart bars, icons |
The spectrum gradient used for dividers and tabs is the --spectrum custom property in :root; the gradient
text class .text-spectrum and the .glass card border are defined in the same file. To change the brand
colour, edit the brand scale:
@theme {
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
}Prism is a dark-only design (color-scheme: dark in globals.css and colorScheme: "dark" in the
viewport export of layout.tsx). There is no light theme or toggle. To lighten it, change the ink and
text tokens together and check contrast again. A few colours are written into SVG art and the benchmark chart
(src/components/benchmark-chart.tsx, src/data/benchmarks.ts); update those too.
Typography#
Three fonts are self-hosted with next/font/local in src/app/fonts.ts, from the Latin woff2 files Google
Fonts serves (src/fonts/): Geist (text and headings), Geist Mono (labels, code, numbers) and
Instrument Serif (the italic accent word in headlines, class .serif, and two logo wordmarks). Each keeps
its real family name, so the stacks --font-sans, --font-mono and --font-serif in @theme and the
font-family attributes in the SVG logos can name them directly. To swap a font, put its woff2 file in
src/fonts/, point the localFont() call at it, change the font-family declaration and update the token.
Content#
Page copy is in each route's page.tsx. The content behind lists and interactive parts lives in src/data/:
| File | Export | What it holds |
|---|---|---|
playground.ts |
PLAYGROUND |
Example prompts with their answers, sources, seconds and token counts |
PLAYGROUND_FALLBACK |
The answer for any prompt that isn't an example ({prompt} is replaced) |
|
MODELS |
Playground models: milliseconds per token and a time factor for the meta line | |
benchmarks.ts |
BENCHMARKS |
Chart models (name, colour) and tests (name, scores in percent) |
pricing.tsx |
PRICING |
Seat prices, included tokens, input share, API prices per model, slider steps, enterprise threshold, calculator defaults |
PLANS, API_PRICES, FAQ |
Plan cards, the API price table and the FAQ | |
developers.tsx |
RESPONSE_SAMPLE, KEY_SAMPLE, REQUEST_SAMPLES, STREAM_SAMPLES |
Highlighted code samples |
ENDPOINTS, SDKS, RATE_LIMITS |
Endpoint list, SDK cards, rate limits table | |
changelog.ts |
TOPICS, RELEASES |
Filter topics (label and dot colour) and releases |
careers.ts |
TEAMS, ROLES |
Teams for the filter and open roles |
nav.ts |
NAV, FOOTER_NAV |
Header menu and footer columns |
To add a playground example, add an object to PLAYGROUND. In answer text, **bold** makes emphasis and
[n] cites source n:
{
id: "churn",
label: "Explain last month’s churn",
prompt: "Why did churn go up in September?",
seconds: 1.8,
tokens: 120,
sources: [{ kind: "Analytics", title: "Churn dashboard, September" }],
answer: [
{ p: "Churn rose to **3.9%**, mostly from monthly plans [1]." },
{ ul: ["First point [1].", "Second point [1]."] },
],
},The example buttons are generated from the array. To add a release, add an entry at the top of RELEASES;
text between backticks is shown as code and the date label is formatted from date:
{
date: "2026-10-22",
version: "v2.1",
title: "Shared briefs",
tags: ["product"],
summary: "Briefs can now be shared with a link.",
items: ["New `share` field on `/v1/briefs`."],
},Open roles are { title, team, location } objects in ROLES; team must be a key of TEAMS.
Images and artwork#
There are no photos. Everything is SVG or CSS:
- Logo —
PrismMarkinsrc/components/logo.tsx(header and footer); the same mark issrc/app/icon.svg, which Next.js serves as the favicon. - Customer logos — inline SVG wordmarks in
src/components/logo-strip.tsx. Each hasrole="img"and anaria-label; keep those when you replace them. - Avatars — inline SVG portraits in the testimonials (
src/app/page.tsx) and the team grid (src/app/about/page.tsx). To use photos, replace each<svg>with an<img>of the same size class (square, at least 400×400 px for the team, 80×80 px for testimonials) and a realalt. - Prism illustration —
public/img/prism-beam.svg(800×400), used in the call-to-action panels and the 404 page throughasset("/img/prism-beam.svg"), which adds the base path. - Product mock-ups — the playground, citation preview, search results, draft editor, briefs and charts are
JSX and inline SVG, marked
aria-hidden="true"where they are decorative. - Background — the grid (
.bg-grid), glow (.aurora) and film grain (.grainon<body>inlayout.tsx, an SVG noise filter) are classes inglobals.css. Removegrainto drop the grain. - Social image —
src/app/opengraph-image.png(1200×630). Editpublic/img/og-image.svgand export it to PNG at 1200×630 to change it; most social sites don't display SVG.
Navigation, header and footer#
The header (src/components/site-header.tsx) and footer (src/components/site-footer.tsx) render on every
page from src/app/layout.tsx. Menu items come from NAV and the footer columns from FOOTER_NAV in
src/data/nav.ts. To add a page, create src/app/<route>/page.tsx with its own metadata, then add it to
NAV (the mobile menu uses the same list plus "Talk to sales"). The current page gets aria-current="page",
which also styles it. The header becomes frosted after scrolling (class is-scrolled).
Features#
Playground#
On the home page, #playground streams a pre-written answer token by token: sources appear first ("Searching
4 sources…"), then the text with a caret, numbered citation links to the sources, and a meta line with model,
time and tokens. The first example runs once when the playground scrolls into view.
- Code:
src/components/playground.tsx; data inPLAYGROUND,PLAYGROUND_FALLBACKandMODELS. - Controls: the example buttons, the prompt (
#pg-prompt, run with Ctrl/Cmd + Enter), the Run button that becomes Stop while streaming, and the model select (#pg-model), which changes the streaming speed viamsPerToken. - Any other prompt gets
PLAYGROUND_FALLBACK, with the visitor's text inserted as text (React escapes it). - Connecting a real model: call your own backend from
run()(never call an AI API with a secret key from the browser), for example a Route Handler that streams, and append each chunk to the answer. The component keeps the answer as tokens (writtencounts how many are shown), so a real stream can push tokens the same way the timer does. - Accessibility: the answer area has
aria-live="polite"andaria-busy="true"while streaming, so screen readers read the finished answer once. Example buttons usearia-pressed. With reduced motion the whole answer appears at once and nothing auto-plays.
Benchmark chart#
src/components/benchmark-chart.tsx draws grouped horizontal bars from BENCHMARKS at the box's real width
and redraws on resize, so labels stay readable on phones. BenchmarkLegend and BenchmarkTable in
src/components/benchmarks.tsx render the legend and the table inside "View as a table" on the server. The
first model uses the spectrum gradient (color: "spectrum"); others take any CSS colour. The SVG has
role="img" and an aria-label that lists every value. Bars grow in when the figure is revealed (.bar rules
in globals.css), instantly with reduced motion.
Pricing and calculator#
src/app/pricing/page.tsx wraps the page in BillingProvider (src/components/billing.tsx):
- Billing switch
BillingToggle(#billing-toggle,role="switch",aria-checked). Plan prices and notes given as{ monthly, annual }inPLANSfollow it throughByBilling. Annual is the default (PRICING.defaults.annual). - Calculator (
src/components/cost-calculator.tsx) with#calc-seats(1–250),#calc-tokens(steps fromPRICING.tokenSteps, in millions) and the model radios. The estimate is seats × seat price (fromPRICING.seat, following the switch) plus billable tokens × a blended price (inputShareinput, the rest output), afterincludedTokensMfree tokens. FromenterpriseSeatsseats a note suggests Enterprise pricing.
Prices are worked out from PRICING; the plan cards and the API table read PLANS and API_PRICES, so keep
those in step when prices change. The sliders announce readable values (aria-valuetext), and the total is
announced politely 0.7 s after changes stop.
Code tabs and copy buttons#
On /developers, each CodeTabs card (src/components/code-tabs.tsx) has a role="tablist" with cURL,
JavaScript and Python tabs and role="tabpanel" panels. Choosing a language switches every card on the page
and is remembered (see Saved data); src/lib/code-lang.ts holds the shared value.
Arrow keys, Home and End move between tabs.
CopyButton (src/components/copy-button.tsx) copies its text prop (the SDK install commands); without
one it copies the visible code block in its [data-tabs] or [data-copy-scope] card. The label changes to
"Copied" for 1.8 s and the live region announces it. Code is coloured with spans (t-k, t-s, t-n, t-c,
t-f, t-p) styled in globals.css; when you change a sample in src/data/developers.tsx you can drop the
spans and keep plain text. Code blocks are focusable so keyboard users can scroll them.
Filters#
src/components/filter.tsx powers the changelog topics and the careers team filter. FilterProvider gets
every item's tags, the URL parameter (tag or team) and the noun for announcements; FilterBar renders
the buttons with counts (aria-pressed), FilterItem hides items that don't match and FilterEmpty shows
when nothing does. The choice is kept in the URL (/changelog?tag=security) with history.replaceState, so
filtered views can be shared; unknown values show everything. "Showing 4 releases" is announced after each
click.
FAQ accordion#
Accordion (src/components/accordion.tsx) renders FAQ from src/data/pricing.tsx. Each button has
aria-expanded and aria-controls pointing at its panel. One answer is open at a time; store a set of open
indexes instead of one number to allow several. Without JavaScript every answer is visible.
Header, menu, reveals and back to top#
The mobile menu (#menu-toggle, #mobile-menu) uses aria-expanded, closes on Escape (returning focus to the
button), on navigation and when the window grows to desktop width. Elements with .reveal fade up as they
enter the viewport (reveal-d1 to reveal-d3 add a delay); src/components/reveal-observer.tsx also reveals
everything above, and nothing is hidden for reduced motion or without JavaScript. The logo strip scrolls slowly
(paused on hover, static for reduced motion). A back-to-top button (src/components/back-to-top.tsx) appears
after one screen of scrolling; remove it from layout.tsx and delete the .back-to-top styles to drop it.
Forms#
There are two forms. Both use useValidation() from src/components/use-validation.tsx, which reads the rules
from each field's attributes (required, type, data-validate, minLength, data-label, data-msg,
data-group) and applies src/lib/validation.ts: messages appear under the field, linked with
aria-describedby and aria-invalid, focus moves to the first problem, and errors update as the visitor types.
| Form | Where | Checks |
|---|---|---|
| Newsletter | src/components/newsletter-form.tsx (footer of every page) |
Email required and well-formed |
| Talk to sales | src/components/contact-form.tsx (/contact) |
First and last name, work email (data-validate="work-email" rejects FREE_EMAIL domains), company, team size, at least one "Interested in" box, optional message of at least 20 characters, consent box |
/contact?topic=careers (or sales, trial, enterprise, security, api, partnerships, other)
preselects the topic; the options are TOPICS in contact-form.tsx.
The demo forms don't send anything. On success they show a message that says so. To connect a form
service such as Formspree, change the callback passed to useValidation():
const v = useValidation(async (form) => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (res.ok) setSent((form.elements.namedItem("first_name") as HTMLInputElement).value.trim());
});Every field has a name, so FormData includes it. If you remove the static export (see below), you can post
to a Route Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request))
or call a Server Action.
Saved data (localStorage)#
| Key | Value | Validation |
|---|---|---|
prism-code-lang |
curl, js or python |
getLang() in src/lib/code-lang.ts ignores anything else and uses curl |
It only stores the preferred code language on /developers. Reading and writing go through
src/lib/storage.ts, which catches errors, so blocked storage only means the choice isn't remembered. To reset
it, run localStorage.removeItem("prism-code-lang") in the console; to stop saving, remove the writeStore()
call in setLang().
SEO and sharing#
Each route sets its title and description with pageMeta() from src/lib/metadata.ts, which also writes the
Open Graph title and description. The image is src/app/opengraph-image.png; set SITE_URL when building so
its URL is absolute (metadataBase in layout.tsx):
SITE_URL=https://prism.example.com pnpm buildThe 404 page is noindex. Add src/app/sitemap.ts and src/app/robots.ts if you want them (both work with
the static export). There is no structured data; Organization and SoftwareApplication JSON-LD are good
additions.
Accessibility#
Built in:
- Skip link to
#main, landmarks (header, labellednavs,main,footer) and oneh1per page with ordered headings. - Visible focus rings (
:focus-visiblein brand aqua) and colour contrast that passes WCAG AA on every surface. - ARIA patterns: menu button with
aria-expanded, tabs with rovingtabIndex, a switch (role="switch"), accordion buttons,aria-pressedfilters and examples, live regions for the playground, calculator, filters and copy buttons, and labelled scrollable tables (role="region",tabIndex={0}). prefers-reduced-motion: no streaming animation, no marquee, no reveals, instant back-to-top.- Decorative SVG and mock-ups are
aria-hidden; meaningful graphics (logos, charts) have labels.
When editing, keep one h1 per page, give every new input a <label>, keep alt="" on decorative images, and
check contrast if you change the ink, muted or subtle tokens.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH (for a
sub-folder, e.g. DEMO_BASE_PATH=/prism pnpm build; asset() adds the same prefix to <img> paths). Deploy
out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel.
demo/ is the freeframe.dev demo build and can be deleted. To use server features (Route Handlers, Server
Actions, a real model behind the playground), remove output: "export" and deploy to a Node.js host or Vercel.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6
API. Replace both with typescript@7 once typescript-eslint supports it.
Troubleshooting#
- The playground answer appears all at once. Your system has "reduce motion" turned on; streaming is intentionally skipped.
- A new example doesn't match when typed. Typed prompts match an example only if the text equals its
prompt(ignoring case and surrounding spaces); anything else gets the fallback answer. - Calculator totals don't match the plan cards. The calculator reads
PRICING; the cards readPLANSand the tableAPI_PRICES. Update all three insrc/data/pricing.tsx. - Copy buttons say "Press Ctrl+C". The browser blocked clipboard access. It works on
localhostand HTTPS. - A Tailwind class written in a template string has no effect. Keep a space between a class name and a
${...}expression (`h-4 w-4 ${on ? "hidden" : ""}`), otherwise Tailwind can't find the class. - Fonts fall back to the system font. Check that
src/fonts/still has the woff2 files named insrc/app/fonts.ts. - The demo's links or images break under a sub-folder. Build with
DEMO_BASE_PATHset to that folder.
License#
MIT, see the repository license (included as LICENSE.txt in the download). All artwork (logo, wordmarks,
avatars, prism illustration and social image) is original SVG and covered by the same license. Geist, Geist
Mono and Instrument Serif are under the SIL Open Font License 1.1 (src/fonts/*-OFL.txt).