Subframe
Components & interactionsVariable font controls

Type instrument

Four red faders sculpt an oversized black glyph on a yellow proofing field.

Move the faders, select a glyph, or reset the specimen

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
"use client"

import { useEffect, useState } from "react"

function useReducedMotion() {
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const query = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(query.matches)
    update()
    query.addEventListener("change", update)
    return () => query.removeEventListener("change", update)
  }, [])
  return reduced
}

export default function TypeInstrument({ active = true }: { active?: boolean }) {
  const reduced = useReducedMotion()
  const [glyph, setGlyph] = useState("g")
  const [width, setWidth] = useState(100),
    [lean, setLean] = useState(0),
    [weight, setWeight] = useState(700),
    [scale, setScale] = useState(100)
  const [instant, setInstant] = useState(false)
  const controls = [
    { label: "Width", value: width, min: 60, max: 130, step: 1, set: setWidth },
    { label: "Lean", value: lean, min: -16, max: 16, step: 1, set: setLean },
    { label: "Weight", value: weight, min: 400, max: 700, step: 300, set: setWeight },
    { label: "Scale", value: scale, min: 65, max: 110, step: 1, set: setScale },
  ]
  return (
    <section className="sf-type-instrument" data-instant={!active || reduced || instant}>
      <div className="sf-type-instrument-proof" aria-label="Live type specimen">
        <div className="sf-type-instrument-baseline" />
        <span
          key={glyph}
          style={{ fontWeight: weight, transform: `scaleX(${width / 100}) skewX(${-lean}deg) scale(${scale / 100})` }}
        >
          {glyph}
        </span>
      </div>
      <div className="sf-type-instrument-rack">
        {controls.map((c) => (
          <label key={c.label}>
            <span>{c.label}</span>
            <input
              aria-label={`Specimen ${c.label.toLowerCase()}`}
              type="range"
              min={c.min}
              max={c.max}
              step={c.step}
              value={c.value}
              onChange={(e) => c.set(Number(e.target.value))}
            />
            <output>{c.value}</output>
          </label>
        ))}
      </div>
      <div className="sf-type-instrument-glyphs">
        {["g", "&", "R"].map((g) => (
          <button
            type="button"
            aria-label={`Show ${g} glyph`}
            aria-pressed={g === glyph}
            key={g}
            onClick={(e) => {
              setInstant(e.detail === 0)
              setGlyph(g)
            }}
          >
            {g}
          </button>
        ))}
        <button
          type="button"
          className="sf-type-instrument-reset"
          onClick={() => {
            setWidth(100)
            setLean(0)
            setWeight(700)
            setScale(100)
          }}
        >
          Reset<span aria-hidden="true">↺</span>
        </button>
      </div>
      <style>{`.sf-type-instrument{position:relative;container-type:size;display:grid;grid-template-columns:1fr clamp(128px,29%,174px);grid-template-rows:1fr 55px;width:100%;height:100%;min-height:280px;overflow:hidden;background:#f9d900;color:#19130d;font:11px Arial,sans-serif}.sf-type-instrument *{box-sizing:border-box}.sf-type-instrument-proof{position:relative;display:grid;place-items:center;overflow:hidden;padding:5px 15px 24px}.sf-type-instrument-proof>span{position:relative;z-index:1;display:inline-block;font-family:Georgia,serif;font-size:clamp(130px,42cqw,260px);line-height:.8;letter-spacing:-.06em;transform-origin:50% 70%;animation:sf-type-glyph 220ms cubic-bezier(.22,1,.36,1)}.sf-type-instrument-baseline{position:absolute;left:22px;right:0;top:77%;border-top:1px solid #9c760080}.sf-type-instrument-baseline:before{content:"";position:absolute;right:0;top:-135px;left:0;border-top:1px dashed #9c760080}.sf-type-instrument-rack{display:grid;grid-template-columns:1fr 1fr;align-content:center;padding:12px 11px 7px 7px;gap:14px 6px;border-left:1px solid #16110a2b;background:#edbc00}.sf-type-instrument-rack label{display:flex;flex-direction:column;align-items:center;gap:2px}.sf-type-instrument-rack label>span{font-size:10px}.sf-type-instrument input{writing-mode:vertical-lr;direction:rtl;appearance:none;width:44px;height:74px;background:transparent;cursor:ns-resize;touch-action:none;margin:0}.sf-type-instrument input::-webkit-slider-runnable-track{width:3px;border-radius:1px;background:#9f6d004d;border:1px solid #9f6d004d}.sf-type-instrument input::-webkit-slider-thumb{appearance:none;width:25px;height:17px;margin-left:-12px;border-radius:3px;background:linear-gradient(#f14f24,#d52908);border:1px solid #a22206;box-shadow:0 3px 3px #6736065c,inset 0 2px 1px #ff9970}.sf-type-instrument input::-moz-range-track{width:3px;background:#9f6d004d}.sf-type-instrument input::-moz-range-thumb{width:25px;height:17px;border-radius:3px;background:#e23a13;border:1px solid #a22206}.sf-type-instrument output{font:10px ui-monospace,monospace;font-variant-numeric:tabular-nums;opacity:.7}.sf-type-instrument-glyphs{grid-column:1/-1;display:flex;align-items:stretch;border-top:1px solid #17110b3b;padding-left:14px;background:#f9d900}.sf-type-instrument button{border:0;background:transparent;color:inherit;min-width:52px;min-height:44px;font:26px Georgia,serif;cursor:pointer}.sf-type-instrument button[aria-pressed=true]{background:#18120d;color:#f9d900}.sf-type-instrument button:active{opacity:.7}.sf-type-instrument .sf-type-instrument-reset{margin-left:auto;display:flex;align-items:center;gap:18px;font:11px Arial;padding:0 20px}.sf-type-instrument-reset span{font-size:20px}.sf-type-instrument :focus-visible{outline:2px solid #111;outline-offset:-4px}.sf-type-instrument[data-instant=true] .sf-type-instrument-proof>span{animation:none}@container(max-width:400px){.sf-type-instrument{grid-template-columns:1fr 130px}.sf-type-instrument-rack{gap:11px 0;padding:8px 2px}.sf-type-instrument-proof{padding-left:0;padding-right:0}.sf-type-instrument-proof>span{font-size:160px}}@keyframes sf-type-glyph{from{opacity:.35;filter:blur(3px)}to{opacity:1;filter:blur(0)}}@media(prefers-reduced-motion:reduce){.sf-type-instrument-proof>span{animation:none!important}}`}</style>
    </section>
  )
}

Where it works well

Type specimen exploration and immediate visual parameter control.

Reduced motion

Glyph changes are immediate. Faders always respond directly without tweening.

Things to know

Uses CSS scale/skew and system Georgia weights. Width and lean are transforms, not variable-font axes; available weights vary by platform.

More examples