specimen
A looping type-specimen video from a font file.
specimen is a local generator — no url. Point it at a font file and it renders a looping
clip of that typeface set as a fixed number of left-aligned lines of glyphs, whose letters and
colours change over a composed sequence of pulses, then captures it. The glyph size is derived
from lines (the rows fill the top type.fill of the frame — 80% by default); the strip below is a
gap that shows the background and the font name.
{
name: "brand-type",
generator: "specimen",
options: {
font: "public/fonts/YourFont.woff2",
name: "Your Font",
template: "sweep",
},
}Only font is required; everything else has a sensible default. A pulse is one beat of the
animation (a named span during which some fraction of the glyph cells change letter and/or colour;
an empty pulse is a hold), and the clip length is the sum of the pulse durations — doubled when
mirror is on, which plays the storyboard out and back for a seamless loop. Omit pulses and a
lively built-in storyboard is used. A template ("sweep" or "demo") loads a named preset, and
your explicit options still override what it sets.
Config options
Only font is required. Reference is the interactive view; TypeScript is the same shape
in code, every option at its default.
fontstringrequiredFont file to showcase (path relative to the working dir, or absolute). Required.
template'demo' | 'sweep'Load a named option preset; your explicit options still override what it sets (deep, per-field). "sweep" is a seamless-looping showcase of even per-character colour sweeps (muted → accent → foreground) on a dark palette chosen so the accent reads. "demo" is a labelled walkthrough of every pulse behaviour with demo mode on; runs once, no mirror.
namestringdefault ''Display name shown in the bottom gap area (e.g. "ABC Oracle"). Position/style it via label.
outputobjectdefault {}
Output frame + encoding settings. Override any subset.
widthnumberdefault 1920Output frame width in px.
heightnumberdefault 1080Output frame height in px.
deviceScaleFactornumber (≤4)default 1Render scale (1 = 1:1; higher = crisper capture, downscaled into the video).
fpsnumber (≤120)default 30Output frames per second.
crfnumber (0–51)default 18x264 quality (lower = better quality / larger file).
fileNamestringOutput filename. Defaults to "<slug(asset name)>.mp4".
typeobjectdefault {}
Glyph typography — the type set on the wall. Override any subset.
weightnumber (100–1000, step 100)default 400Glyph weight, in steps of 100 to match the weights a font actually ships. 400 = regular.
linesnumber (1–40)default 3Number of glyph rows. The glyph size is derived so the rows fill "fill" of the frame height.
fillnumber (0–1)default 0.8Fraction of the frame height the glyph rows fill; the strip below is the label gap area. Raise toward 1 for a fuller wall, lower it to make more room for the name label.
leadingnumberdefault 0.78Line-height of the rows (tight, cap-height-hugging). The tight default clips below the baseline, so lowercase descenders (g j p q y) in a custom pool get cut off — raise to ~1 if you include them.
padding'xs' | 'sm' | 'base' | 'lg' | 'xl' | percentage string | number (px)default 'base'Side margin the glyph wall is inset by, left and right — a step of the size scale, a percentage of the frame height like "3%", or px as a number. "base" is 32px at 1080p. Steps and percentages keep the margin in proportion to the glyphs, which are sized from the frame height, so the framing holds at any output size; px pins it. Matches label.padding by default, so the wall's edges line up with the name label below it. Lines justify flush to the remaining content width, so this is a margin you can rely on. Set 0 for an edge-to-edge wall that bleeds off the frame.
opticalMarginsbooleandefault trueFlush each line's ink to the margins rather than its metrics. Advance widths include a little blank side bearing per glyph, so a metrically-aligned line still reads as ragged against the frame edge. This slides just the first and last glyph inside their fixed boxes to cancel it. Neighbours are unaffected and no glyph is frozen. Set false to align by metrics.
blackliststringdefault ''Glyphs to exclude from the pool, case-insensitively (e.g. "qxz" and "QXZ" both drop Q, X, Z). A blacklist that would empty the pool is ignored.
characterPoolstringOverride the glyph pool (needs ≥2 distinct characters). Defaults to A–Z, 0–9, and the symbols $ % & @ # * + =. Digits always render as lining figures.
widthTolerancenumber (0–0.5)default 0.02How close in width two glyphs must be to substitute for each other, as a fraction of advance. Every cell is a fixed-width box and only ever holds glyphs within this tolerance of the one it was seeded with, so a glyph change moves nothing at all. Raise it for more glyphs per cell (and slightly looser, less even letter gaps); lower it for tighter spacing (and fewer glyphs able to change). Glyphs left with no width-compatible partner still appear but never change — the run logs which ones.
labelobjectdefault {}
Placement + styling of the name label within the bottom gap area. Position is confined to the gap (the label moves around it, never over the glyphs).
anchor'top-left' | 'top-center' | 'top-right' | 'middle-left' | 'middle-center' | 'middle-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'default 'bottom-left'Corner/edge of the gap the label anchors to.
padding'xs' | 'sm' | 'base' | 'lg' | 'xl' | percentage string | number (px)default 'base'Inset from the gap edges, all around, measured to the visible text — a step of the size scale, a percentage of the frame height like "3%", or px as a number. "base" is 32px at 1080p. Steps and percentages keep the inset in proportion to the label text, which is sized from the frame height, so the corner reads the same at any output size; px pins it. 0 = flush to the rendered corner.
size'xs' | 'sm' | 'base' | 'lg' | 'xl' | number (0–1)default 'base'Text size — a step of the size scale, or a fraction of the gap-area height. "base" is 0.22. Either way it scales with the frame height.
weightnumber (1–1000)default 500Label font weight.
colorstringLabel text colour (any CSS colour). Falls back to colors.foreground if unset.
colorsobjectdefault light-grey palette
Colour tokens the glyphs cycle through (any CSS colour strings). Override any subset; each key falls back independently.
backgroundstringdefault '#eceef1'Backdrop behind the glyphs (and the bottom gap band).
foregroundstringdefault '#16181d'Primary glyph colour — the resting majority.
mutedstringdefault '#a7adb6'Muted/secondary glyph colour.
accentstringAccent colour for occasional pops. Falls back to background if unset (accent glyphs blend into the backdrop) — set it (or a template) to make accent reads visible.
colorWeightsobjectdefault 2 / 2 / 1
Relative likelihood of each token on a random (non-targeted) recolour. A weight of 0 excludes that token from random recolouring (an explicit pulse color can still target it).
foregroundnumber (≥0)default 2Relative likelihood of the foreground token on a random colour change.
mutednumber (≥0)default 2Relative likelihood of the muted token on a random colour change.
accentnumber (≥0)default 1Relative likelihood of the accent token on a random colour change.
pulsesPulse[]default built-in storyboard
The animation storyboard: an ordered sequence of beats (min 1). Omit for the built-in default (a lively ~10s outward half, mirrored into a ~20s loop). The clip length is the sum of the pulse durations (doubled when mirror is on).
namestringdefault ''Human label for the beat (readability only).
durationMsnumberrequiredLength of the beat, in milliseconds. Required, must be positive.
charsnumber (≥0)default 0Fraction of cells whose glyph changes during the beat (1 = every cell once; above 1 repeats cells; 0 = a hold).
colorsnumber (≥0)default 0Fraction of cells whose colour changes during the beat (1 = every cell once; above 1 repeats cells).
color'foreground' | 'muted' | 'accent'Target every colour change at this token (a deliberate sweep) instead of a weighted-random pick. Set colors: 1 with pacing: "even" to wash the whole specimen to one colour evenly. Omit for the default scattered recolour.
pacing'even' | 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'random'default 'even'How changes are distributed in time across the beat. even/linear are uniform; ease-in front-loads; ease-out back-loads; ease-in-out bunches at both ends; random scatters.
animationobjectdefault {}
Animation timing + behaviour. Override any subset.
durationMsnumberClip length in ms. Defaults to the (mirrored) sum of the pulse durations; set to override.
mirrorbooleandefault truePlay the pulses out and back as a palindrome for a seamless loop; doubles the clip length. Set false for a one-shot that ends on the last state.
seednumberdefault 1Schedule seed — same seed ⇒ identical animation. Change for a different (still deterministic) take.
characterIntensitynumber (≥0)default 1Multiply every pulse's glyph-change fraction (1 = baseline, 2 = twice as busy, 0 = none).
colorIntensitynumber (≥0)default 1Multiply every pulse's colour-change fraction (1 = baseline, 2 = twice as busy, 0 = none).
demobooleandefault falseOverlay the active pulse's name bottom-right, to see which beat is playing.
{
name: "brand-type",
generator: "specimen",
options: {
// --- source & preset (specimen is local: no url) ---
font: "public/fonts/YourFont.woff2",
// template: "sweep", // optional; "sweep" | "demo" preset (your options still win)
// --- name label (lives in the bottom gap) ---
name: "",
label: {
anchor: "bottom-left",
padding: 32,
size: 0.22,
weight: 500,
// color: "#b49a77", // optional; falls back to colors.foreground
},
// --- type & layout ---
type: {
weight: 400, // steps of 100 (100–1000)
lines: 3,
fill: 0.8, // fraction of the frame the rows fill
leading: 0.78,
padding: 32, // side margin the wall justifies within (0 = edge-to-edge)
opticalMargins: true, // flush the ink to the margin, not the metrics
blacklist: "",
widthTolerance: 0.02, // ±2% — how close in width a glyph swap must be
// characterPool: "ABC…", // optional; defaults to A–Z 0–9 + symbols ($ % & @ # * + =)
},
// --- colour ---
colors: {
background: "#eceef1",
foreground: "#16181d",
muted: "#a7adb6",
// accent: "#7c9cff", // optional; falls back to background (accent blends in)
},
colorWeights: {
foreground: 2,
muted: 2,
accent: 1,
},
// --- storyboard (omit pulses for the built-in default) ---
// pulses: [{ name: "hold", durationMs: 800 }, { name: "to accent", durationMs: 2200, colors: 1, color: "accent", pacing: "ease-in-out" }],
// --- animation (timing, loop & determinism) ---
animation: {
mirror: true,
seed: 1,
characterIntensity: 1,
colorIntensity: 1,
demo: false,
// durationMs: 20000, // optional; defaults to the (mirrored) sum of pulse durations
},
// --- output & encoding ---
output: {
width: 1920,
height: 1080,
deviceScaleFactor: 1,
fps: 30,
crf: 18,
// fileName: "brand-type.mp4", // optional; defaults to <slug(name)>.mp4
},
},
}
seedmakes the otherwise-scattered animation deterministic: the same seed always produces the same take. Change it for a different (still reproducible) result. Every option also has hover docs inpro-visu.config.ts— the authoring types are generated from the validation schema.
A specimen is sized off the frame height: the glyphs are derived from it, the label text is a
fraction of the gap area beneath them, and the two padding options resolve against it too. So the
whole composition holds its proportions at any output size — set output to 4K and everything grows
with it, no other options to touch.
Both paddings and label.size take a step of the size scale — xs, sm, base, lg, xl —
which is the easiest way to say "a bit tighter" or "a bit bigger" without picking a number. base
is the shipped default in each case. Reach past the scale when you need to: a percentage of the
frame height ("4.5%") or a fraction for label.size (0.16) is exact and still scales, and a
plain number of px pins a padding at that size whatever the output.
Examples
Start from a template for an instant look, or compose the options into your own. The settings
reinforce one another, so a handful of decisions tend to travel together — each recipe below
foregrounds one such decision and lets the rest fall in behind it.
Templates
A template loads a named preset; your explicit options still override what it sets.
template: "sweep" is a seamless-looping showcase of even per-character colour sweeps
(muted → accent → foreground) on a dark palette chosen so the accent reads.
{name: "type-sans",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "Inter", template: "sweep", type: { lines: 4 },},}template: "demo" walks through every pulse behaviour one beat at a time with demo mode on, so it
overlays each beat's name as it plays — you can see exactly what every pulse setting does.
{name: "type-demo",generator: "specimen",options: { font: "public/fonts/Fraunces.woff2", name: "Fraunces", template: "demo",},}Aspect ratio
The frame's shape decides how the type is set. A tall 9:16 frame has vertical room for many
lines at a high fill; a wide letterbox wants a single big line. Tabular digits in a monospaced
face lock to a flawless grid.
{name: "numeric-ticker",generator: "specimen",options: { font: "public/fonts/JetBrainsMono.woff2", name: "0–9", output: { width: 1080, height: 1920 }, // 9:16 — vertical room for the rows type: { lines: 12, // many rows… fill: 0.94, // …want a high fill to use the height characterPool: "0123456789", // mono digits ⇒ a flawless grid weight: 500, }, colors: { background: "#0b0b0f", foreground: "#e8e8ea", muted: "#4b5563", accent: "#6ee7a8" }, label: { anchor: "bottom-center", size: 0.16 }, pulses: [ { name: "flip", durationMs: 1400, chars: 0.6, pacing: "random" }, { name: "accent", durationMs: 800, colors: 0.2, color: "accent" }, ],},}{name: "wordmark-banner",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "Inter", output: { width: 2560, height: 640 }, // 4:1 letterbox type: { lines: 1, fill: 0.72, weight: 800 }, // one fat line suits the strip label: { anchor: "bottom-right" },},}Weight & density
Glyph weight reads best at a matching density. A hairline only holds up large — few lines,
airy leading, calm motion. A black weight wants the opposite: pack the rows tight and let it churn.
{name: "hairline",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "Inter Thin", type: { weight: 100, lines: 4, leading: 1.15 }, // thin + airy = spacious label: { anchor: "bottom-left", weight: 300 }, animation: { characterIntensity: 0.4, colorIntensity: 0.3 }, // calm, to match},}{name: "heavy",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "Inter Black", type: { weight: 900, lines: 10, leading: 0.72 }, // black wants dense, tight rows colors: { background: "#0b0b0f", foreground: "#f4f4f5", accent: "#7c9cff" }, label: { anchor: "bottom-right" }, animation: { characterIntensity: 1.8, colorIntensity: 1.3 }, // busy churn},}Colour
colorWeights and colorIntensity decide how much colour carries the piece. Bias the accent and run
colour hot — while keeping the glyphs calm — and colour leads. Omit the accent (it falls back to the
background, so pops vanish) and let muted dominate for a restrained, single-hue wall.
{name: "accent-led",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "accent-led", colors: { background: "#07070a", foreground: "#e8e8ea", muted: "#2a2a33", accent: "#6ee7a8" }, colorWeights: { foreground: 1, muted: 1, accent: 4 }, // accent 4× as likely label: { anchor: "bottom-left", color: "#6ee7a8" }, animation: { characterIntensity: 0.5, colorIntensity: 1.8 }, // colour moves, glyphs rest},}{name: "monochrome",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "monochrome", colors: { background: "#f6f3ed", foreground: "#1a1714", muted: "#b49a77" }, // no accent ⇒ = background colorWeights: { foreground: 1, muted: 3, accent: 0 }, // pops disabled; muted leads label: { anchor: "bottom-left", color: "#b49a77" }, animation: { characterIntensity: 0.4, colorIntensity: 0.5 },},}Glyph pool
Swap characterPool and the specimen changes character — but the pool couples to leading. A
lowercase pool has descenders (g j p q y) that the tight default clips, so raise leading toward 1.
A symbol pool locks to an even grid in a monospaced face.
{name: "text-face",generator: "specimen",options: { font: "public/fonts/Fraunces.woff2", name: "Fraunces text", type: { characterPool: "abcdefghijklmnopqrstuvwxyz", // lowercase ⇒ descenders leading: 1.0, // raised so g j p q y don't clip lines: 4, }, colors: { background: "#f6f3ed", foreground: "#1a1714", muted: "#b49a77", accent: "#8a5a3c" }, label: { anchor: "bottom-left", color: "#8a5a3c" },},}{name: "symbols",generator: "specimen",options: { font: "public/fonts/JetBrainsMono.woff2", name: "JetBrains Mono · symbols", type: { characterPool: "!@#$%^&*()_+-=[]{};:,.<>/?", // punctuation set lines: 6, // mono ⇒ even grid }, colors: { background: "#0b0f10", foreground: "#cdd6d3", muted: "#586460", accent: "#6ee7a8" }, label: { anchor: "bottom-right" },},}Motion
mirror (on by default) plays the storyboard out and back for a seamless loop. Turn it off for a
one-shot that ends on its final state — write the pulses to assemble from near-blank and hold, like
an intro sting.
{name: "one-shot",generator: "specimen",options: { font: "public/fonts/Inter.woff2", name: "one-shot", type: { lines: 3 }, label: { anchor: "bottom-left" }, animation: { mirror: false }, // ends on the last state instead of looping back pulses: [ { name: "sparse", durationMs: 600 }, { name: "assemble", durationMs: 2200, chars: 0.9, pacing: "ease-out" }, { name: "colour in", durationMs: 1600, colors: 0.7, pacing: "ease-in-out" }, { name: "hold final", durationMs: 1600 }, ],},}








