Type Fighter
Installation
npx shadcn@latest add https://uibrik.com/r/type-fighter.jsonInstall the packages
npm install @chenglou/pretext motionCopy the source into your project
components/uibrik/type-fighter/type-fighter.tsx"use client"; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { clearCache, measureNaturalWidth, prepareWithSegments } from "@chenglou/pretext"; import { GLYPHS, MISSING_ADVANCE, UNITS_PER_EM, X_HEIGHT } from "./ems-allure"; // The real <input> owns everything a text field should: focus, caret, // selection, IME, paste, undo, a11y. Its glyphs are painted transparent and // an overlay redraws each character as its own span, so every character can // fly in on its own path. Pretext measures where each glyph sits — no DOM // reflow per keystroke, just arithmetic over cached canvas widths. // // Two ways to arrive. Comet (default): each glyph is flung in from beyond the // viewport and strikes the field in a burst of sparks. Cursive: the field // switches to a single-stroke script (EMS Allure) and every letter is written // with a pen: each stroke is drawn along its own path, in the order a hand // would draw it, lifting between strokes. The script's letters join at the // baseline, and letters are written strictly one after another, so each one // flows out of the end of the previous — word into word. The caret never // waits for the pen; the pen speeds up when it falls behind and catches up. // // Impact sparks live on one full-screen canvas. Each glyph schedules a burst // for the instant it strikes the field; the burst reads the layer's position // at that moment, so it lands on the glyph even if the field scrolled. export type TypeFighterMode = "comet" | "cursive"; type Mode = TypeFighterMode; type Point = { x: number; y: number }; type Char = { id: number; ch: string; // Launch point, in the glyph layer's coordinate space. Null for characters // that were already on screen, so they never re-fly on re-render. from: Point | null; delay: number; tilt: number; // True until the glyph strikes its slot. The caret waits behind the first // glyph still in the air. flying: boolean; // Cursive only: when each pen stroke starts and how long it takes, in // seconds from arrival. Null for glyphs that appear already written. strokes: { delay: number; duration: number }[] | null; }; // A pending strike. `at` is in layer space; `dir` is the unit vector of the // incoming flight. type Strike = { at: Point; dir: Point; hitsAt: number; }; type Particle = { x: number; y: number; vx: number; vy: number; life: number; maxLife: number; width: number; }; const LINE_HEIGHT = 24; // Strike profile: accelerate the whole way and arrive at full speed, so the // glyph hits the field instead of drifting into it. The impact sparks do the // stopping. const FLY_EASE = [0.45, 0, 0.9, 0.55] as const; // The caret slides to its next slot instead of teleporting, so each strike // reads as knocking it forward. const CARET_SLIDE = 0.08; // Pastes arrive as a quick volley rather than a single clump. const PASTE_STAGGER = 0.018; const MAX_STAGGER = 0.24; // Handwriting // Size of the script (px per em). Its x-height is small, so it runs larger // than the 16px comet text to read at a similar size. const INK_SIZE = 24; const INK_SCALE = INK_SIZE / UNITS_PER_EM; // Baseline chosen so the x-height sits centered in the line. const INK_BASELINE = LINE_HEIGHT / 2 + (X_HEIGHT * INK_SCALE) / 2; const INK_WIDTH = 1.5; // Pen speed along the stroke, px/s, and the pause when it lifts between // strokes. Roughly a quick, relaxed hand. const PEN_LIFT = 0.03; // When typing outruns the pen, each second of backlog adds this much speed, // up to MAX_BOOST×, so the pen hurries to catch up instead of queueing forever. const CATCH_UP = 4; const MAX_BOOST = 6; // Impact // Fraction of velocity a spark keeps after one second. const DRAG = 0.02; const GRAVITY = 520; // px/s² // Streak length, in seconds of travel. const STREAK = 0.022; const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null; const graphemes = (s: string) => segmenter ? Array.from(segmenter.segment(s), (g) => g.segment) : Array.from(s); // Trailing whitespace hangs past the line edge in Pretext (as in CSS), so a // prefix ending in a space would measure short. Measuring against a sentinel // glyph and subtracting it keeps those spaces counted. const SENTINEL = "|"; // Widths per font string. Module-level so a mode switch can measure with the // new face before React has re-rendered with it. const widthCache = new Map<string, Map<string, number>>(); function measureWidth(font: string | null, text: string) { if (!font || !text) return 0; let cache = widthCache.get(font); if (!cache) widthCache.set(font, (cache = new Map())); const hit = cache.get(text); if (hit !== undefined) return hit; const opts = { whiteSpace: "pre-wrap" as const }; const sentinel = measureNaturalWidth(prepareWithSegments(SENTINEL, font, opts)); const w = measureNaturalWidth(prepareWithSegments(text + SENTINEL, font, opts)) - sentinel; cache.set(text, w); return w; } // Measurements taken before a webfont finished loading are wrong; drop them. function clearMeasurements() { clearCache(); widthCache.clear(); } function useWidthOf(font: string | null) { return useCallback((text: string) => measureWidth(font, text), [font]); } const fontOf = (el: HTMLElement) => { const s = getComputedStyle(el); return `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`; }; // A random point just beyond the viewport's edge, in viewport coordinates. function offscreenPoint(): Point { const vw = window.innerWidth; const vh = window.innerHeight; const angle = Math.random() * Math.PI * 2; const radius = Math.hypot(vw, vh) / 2 + 48; return { x: vw / 2 + Math.cos(angle) * radius, y: vh / 2 + Math.sin(angle) * radius }; } const rand = (min: number, max: number) => min + Math.random() * (max - min); type InkStroke = { d: string; length: number }; type InkGlyph = { advance: number; strokes: InkStroke[] }; // Glyph strokes in layer pixels, smoothed from the font's polylines with a // Catmull–Rom spline so curves read as ink rather than segments. const inkCache = new Map<string, InkGlyph>(); function inkGlyph(ch: string): InkGlyph { const hit = inkCache.get(ch); if (hit) return hit; const [advance, polylines] = GLYPHS[ch] ?? [MISSING_ADVANCE, []]; const strokes = polylines.map((flat) => { const pts: Point[] = []; for (let i = 0; i < flat.length; i += 2) { pts.push({ x: flat[i] * INK_SCALE, y: INK_BASELINE - flat[i + 1] * INK_SCALE }); } let length = 0; let d = `M${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i - 1] ?? pts[i]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2] ?? p2; const c1x = p1.x + (p2.x - p0.x) / 6; const c1y = p1.y + (p2.y - p0.y) / 6; const c2x = p2.x - (p3.x - p1.x) / 6; const c2y = p2.y - (p3.y - p1.y) / 6; d += `C${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`; length += Math.hypot(p2.x - p1.x, p2.y - p1.y); } return { d, length }; }); const glyph = { advance: advance * INK_SCALE, strokes }; inkCache.set(ch, glyph); return glyph; } const inkWidth = (chars: string[]) => chars.reduce((w, ch) => w + inkGlyph(ch).advance, 0); // Owns the canvas, pending strikes and sparks. Runs a rAF loop only while // something is pending or airborne, so an idle field costs nothing. function useImpact(layerRef: React.RefObject<HTMLDivElement | null>, SPARK_COUNT: number) { const canvasRef = useRef<HTMLCanvasElement>(null); const strikes = useRef<Strike[]>([]); const sparks = useRef<Particle[]>([]); const frame = useRef<number | null>(null); const last = useRef(0); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const resize = () => { const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); window.addEventListener("resize", resize); return () => { window.removeEventListener("resize", resize); if (frame.current !== null) cancelAnimationFrame(frame.current); }; }, []); const tick = useCallback( function step(now: number) { const ctx = canvasRef.current?.getContext("2d"); const layer = layerRef.current?.getBoundingClientRect(); if (!ctx || !layer) { frame.current = null; return; } const dt = Math.min((now - last.current) / 1000, 1 / 30); last.current = now; // Detonate every strike whose glyph has reached the field. strikes.current = strikes.current.filter((s) => { if (now < s.hitsAt) return true; const x = layer.left + s.at.x; const y = layer.top + s.at.y; // Most sparks ricochet back along the approach and fan out sideways, // like debris kicked off a surface; the rest scatter all round. const back = Math.atan2(-s.dir.y, -s.dir.x); for (let i = 0; i < SPARK_COUNT; i++) { const a = i < SPARK_COUNT * 0.7 ? back + rand(-1.25, 1.25) : rand(0, Math.PI * 2); const speed = rand(90, 300); const maxLife = rand(0.18, 0.42); sparks.current.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed, life: maxLife, maxLife, width: rand(0.8, 1.6), }); } return false; }); const decay = Math.pow(DRAG, dt); ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--foreground").trim() || "#171717"; ctx.lineCap = "round"; sparks.current = sparks.current.filter((p) => { p.life -= dt; if (p.life <= 0) return false; p.vx *= decay; p.vy = p.vy * decay + GRAVITY * dt; p.x += p.vx * dt; p.y += p.vy * dt; // Drawn as a streak along its velocity: fast sparks read as sharp // lines, and they shrink to dots as they slow and die. const k = p.life / p.maxLife; ctx.globalAlpha = k * 0.9; ctx.lineWidth = p.width; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - p.vx * STREAK, p.y - p.vy * STREAK); ctx.stroke(); return true; }); ctx.globalAlpha = 1; frame.current = strikes.current.length || sparks.current.length ? requestAnimationFrame(step) : null; }, [layerRef], ); const schedule = useCallback( (strike: Omit<Strike, "hitsAt">, delay: number) => { strikes.current.push({ ...strike, hitsAt: performance.now() + delay * 1000 }); if (frame.current === null) { last.current = performance.now(); frame.current = requestAnimationFrame(tick); } }, [tick], ); return { canvasRef, schedule }; } export type TypeFighterProps = { /** Typing style: flung comets or a pen. Changing it re-lays the current text. */ mode?: TypeFighterMode; placeholder?: string; /** Seconds a comet glyph takes to fly in. */ flyDuration?: number; /** Sparks per impact. */ sparkCount?: number; /** Cursive pen speed, px per second. */ penSpeed?: number; }; export function TypeFighter({ mode = "comet", placeholder = "Type something", flyDuration: FLY_DURATION = 0.3, sparkCount = 14, penSpeed: PEN_SPEED = 380, }: TypeFighterProps) { const inputRef = useRef<HTMLInputElement>(null); const layerRef = useRef<HTMLDivElement>(null); const nextId = useRef(0); const reduceMotion = useReducedMotion(); const { canvasRef, schedule } = useImpact(layerRef, sparkCount); // When the pen will be free to start the next letter (performance.now ms). const penFreeAt = useRef(0); const [value, setValue] = useState(""); const [chars, setChars] = useState<Char[]>([]); const [font, setFont] = useState<string | null>(null); const [scrollLeft, setScrollLeft] = useState(0); const [viewWidth, setViewWidth] = useState(0); const [selection, setSelection] = useState({ start: 0, end: 0 }); const [focused, setFocused] = useState(false); const widthOf = useWidthOf(font); // Read the input's resolved font so Pretext measures the exact face the // browser uses for the caret. const readFont = useCallback(() => { const input = inputRef.current; if (!input) return; const s = getComputedStyle(input); setFont(fontOf(input)); const padding = parseFloat(s.paddingLeft) + parseFloat(s.paddingRight); setViewWidth(input.clientWidth - padding); }, []); useEffect(() => { const input = inputRef.current; if (!input) return; const ro = new ResizeObserver(readFont); ro.observe(input); return () => ro.disconnect(); }, [readFont]); const release = useCallback((id: number, after: number) => { window.setTimeout( () => setChars((cs) => cs.map((c) => (c.id === id ? { ...c, flying: false } : c))), after * 1000, ); }, []); // New glyphs for `all.slice(start, start + count)`, arriving in `mode`, // positioned with `font`. Both are passed in so a mode switch can build // with the new face before it lands in state. const arrive = useCallback( (all: string[], start: number, count: number, font: string | null, mode: Mode): Char[] => { const layer = layerRef.current?.getBoundingClientRect(); // Cursive: how far behind the pen is when this batch arrives sets its // speed for the whole batch, so a paste or replay is written at one // steady pace rather than accelerating letter by letter. const now = performance.now(); const behind = Math.max(0, penFreeAt.current - now) / 1000; const speed = PEN_SPEED * Math.min(MAX_BOOST, 1 + behind * CATCH_UP); return all.slice(start, start + count).map((ch, i): Char => { const id = nextId.current++; const base = { id, ch, from: null, delay: 0, tilt: 0, flying: false, strokes: null }; if (!layer || reduceMotion) return base; if (mode === "cursive") { // The pen writes letters strictly in order: this one starts where // the previous one ends. The caret doesn't wait (flying stays // false); instead the pen speeds up with its backlog. const at = Math.max(now, penFreeAt.current); const backlog = (at - now) / 1000; let t = 0; const strokes = inkGlyph(ch).strokes.map((stroke, k) => { if (k > 0) t += PEN_LIFT; const timing = { delay: backlog + t, duration: stroke.length / speed }; t += timing.duration; return timing; }); penFreeAt.current = at + t * 1000; return { ...base, strokes }; } const delay = Math.min(i * PASTE_STAGGER, MAX_STAGGER); const p = offscreenPoint(); const from = { x: p.x - layer.left, y: p.y - layer.top }; const toX = measureWidth(font, all.slice(0, start + i).join("")); // Nose-first: lean into the direction of travel, straighten on landing. const tilt = Math.max(-18, Math.min(18, (toX - from.x) / 40)); // Sparks burst from the glyph's center, where it strikes. if (ch.trim()) { const cx = measureWidth(font, ch) / 2; const cy = LINE_HEIGHT / 2; const dx = toX - from.x; const dy = -from.y; const len = Math.hypot(dx, dy) || 1; schedule( { at: { x: toX + cx, y: cy }, dir: { x: dx / len, y: dy / len } }, delay + FLY_DURATION, ); } // Release the caret the instant this glyph strikes. release(id, delay + FLY_DURATION); return { ...base, from, delay, tilt, flying: true }; }); }, [reduceMotion, release, schedule], ); // On mount and on every mode switch: wait for the face to load, re-measure, // then rewrite whatever is in the field in the new style. useEffect(() => { const input = inputRef.current; if (!input) return; let cancelled = false; const face = fontOf(input); document.fonts.load(face).then(() => { if (cancelled) return; clearMeasurements(); readFont(); const all = graphemes(input.value); if (!all.length) return; penFreeAt.current = 0; setChars(arrive(all, 0, all.length, face, mode)); }); return () => { cancelled = true; }; }, [mode, arrive, readFont]); const syncScroll = useCallback(() => { const input = inputRef.current; if (input) setScrollLeft(input.scrollLeft); }, []); const syncSelection = useCallback(() => { const input = inputRef.current; if (!input) return; setSelection({ start: input.selectionStart ?? 0, end: input.selectionEnd ?? 0 }); setScrollLeft(input.scrollLeft); }, []); // The input scrolls itself to keep the caret in view after each edit; // follow it once the browser has applied the new value. useLayoutEffect(syncScroll, [value, syncScroll]); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const next = e.target.value; const prev = chars; const nextG = graphemes(next); // Diff by common prefix/suffix: untouched characters keep their ids (and // stay put), only the edited middle is replaced. let start = 0; while (start < prev.length && start < nextG.length && prev[start].ch === nextG[start]) start++; let end = 0; while ( end < prev.length - start && end < nextG.length - start && prev[prev.length - 1 - end].ch === nextG[nextG.length - 1 - end] ) end++; const inserted = arrive(nextG, start, nextG.length - end - start, font, mode); setChars([...prev.slice(0, start), ...inserted, ...prev.slice(prev.length - end)]); setValue(next); setSelection({ start: e.target.selectionStart ?? 0, end: e.target.selectionEnd ?? 0 }); }; // Settled x of each character: the rendered width of everything before it. // Cursive lays out from the stroke font's own advances. const cursive = mode === "cursive"; const text = chars.map((c) => c.ch); const xs: number[] = []; if (cursive) { let x = 0; for (const ch of text) { xs.push(x); x += inkGlyph(ch).advance; } } else { text.forEach((_, i) => xs.push(widthOf(text.slice(0, i).join("")))); } const lineWidth = cursive ? inkWidth(text) : widthOf(text.join("")); const xAt = (i: number) => (i < xs.length ? xs[i] : lineWidth); // Our own caret. The native one jumps ahead the moment a key is pressed, // leaving an empty slot while the glyph is still in the air. This one holds // at the first glyph that hasn't landed and advances as each one strikes. const collapsed = selection.start === selection.end; const caretIndex = graphemes(value.slice(0, selection.start)).length; const waitingOn = chars.findIndex((c, i) => i < caretIndex && c.flying); const caretSlot = waitingOn === -1 ? caretIndex : waitingOn; const caretX = xAt(caretSlot); // The native input lays text out in its own font, so in cursive mode its // scroll and selection don't match the ink. Scroll to keep our caret in // view, and draw the selection from our own layout instead. const offset = cursive ? Math.max(0, caretX + 4 - viewWidth) : scrollLeft; const selStart = xAt(graphemes(value.slice(0, selection.start)).length); const selEnd = xAt(graphemes(value.slice(0, selection.end)).length); // Clicks land by the native layout; re-place the caret by the ink's. const placeCaret = (e: React.MouseEvent<HTMLInputElement>) => { const input = inputRef.current; const layer = layerRef.current?.getBoundingClientRect(); if (!cursive || !input || !layer || input.selectionStart !== input.selectionEnd) return; const x = e.clientX - layer.left; let index = 0; for (let i = 0; i <= text.length; i++) { if (Math.abs(xAt(i) - x) < Math.abs(xAt(index) - x)) index = i; } const at = text.slice(0, index).join("").length; input.setSelectionRange(at, at); syncSelection(); }; return ( <div className={`w-full h-screen overflow-hidden flex flex-col items-center justify-center gap-6 px-4`} > <canvas ref={canvasRef} aria-hidden className="pointer-events-none fixed inset-0 z-10 w-screen h-screen" /> <div className="relative w-full max-w-105"> <input ref={inputRef} value={value} onChange={handleChange} onScroll={syncScroll} onSelect={syncSelection} onMouseUp={placeCaret} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} aria-label="Message" placeholder={placeholder} spellCheck={false} autoComplete="off" className={`w-full h-12 px-4 rounded-xl bg-muted text-[16px] leading-6 font-medium text-transparent caret-transparent outline-none transition-shadow duration-200 focus-visible:ring-4 focus-visible:ring-black/4 dark:focus-visible:ring-white/6 ${ cursive ? "placeholder:text-transparent selection:bg-transparent" : "placeholder:text-neutral-400 dark:placeholder:text-neutral-500 selection:bg-neutral-300/60 dark:selection:bg-neutral-600/60" }`} /> {/* Glyph layer. Deliberately unclipped so characters are visible for their whole flight; settled glyphs scrolled out of the field fade. */} <div aria-hidden className="pointer-events-none absolute inset-y-0 left-4 right-4 z-20 flex items-center" // Comet glyphs must fly in from anywhere; handwriting stays inside // the field (with room above and below for tall script letters). style={{ clipPath: mode === "cursive" ? "inset(-24px -4px -24px -12px)" : undefined }} > <div ref={layerRef} className="relative w-full" style={{ height: LINE_HEIGHT, transform: `translateX(${-offset}px)` }} > {cursive && !collapsed && ( <div className="absolute -top-1 -bottom-1 rounded-md bg-neutral-300/60 dark:bg-neutral-600/60" style={{ left: Math.min(selStart, selEnd), width: Math.abs(selEnd - selStart) }} /> )} {cursive && ( <svg width="1" height={LINE_HEIGHT} className="absolute top-0 left-0 overflow-visible text-foreground" fill="none" stroke="currentColor" strokeWidth={INK_WIDTH} strokeLinecap="round" strokeLinejoin="round" > {!value && <InkLine text={placeholder} className="text-neutral-400 dark:text-neutral-500" />} <AnimatePresence initial={false}> {chars.map((c, i) => ( <motion.g key={c.id} initial={false} animate={{ x: xs[i] }} exit={{ opacity: 0, filter: "blur(2px)", transition: { duration: 0.12 } }} transition={{ duration: 0.18, ease: "easeOut" }} > {inkGlyph(c.ch).strokes.map((stroke, k) => { return <PenStroke key={k} d={stroke.d} timing={c.strokes?.[k]} />; })} </motion.g> ))} </AnimatePresence> </svg> )} <AnimatePresence initial={false}> {mode === "comet" && chars.map((c, i) => { const x = xs[i]; const visible = x - scrollLeft >= -1 && x - scrollLeft <= viewWidth; const fly = { duration: FLY_DURATION, ease: FLY_EASE, delay: c.delay }; return ( <motion.span key={c.id} className="absolute top-0 left-0 whitespace-pre text-[16px] leading-6 font-medium text-foreground will-change-transform" initial={ c.from ? { x: c.from.x, y: c.from.y, rotate: c.tilt, filter: "blur(2px)" } : false } animate={{ x, y: 0, rotate: 0, filter: "blur(0px)", opacity: visible ? 1 : 0 }} exit={{ opacity: 0, filter: "blur(2px)", transition: { duration: 0.12 } }} transition={{ x: c.from ? fly : { duration: 0.18, ease: "easeOut" }, y: fly, rotate: fly, filter: fly, opacity: { duration: 0.12 }, }} > {c.ch} </motion.span> ); })} </AnimatePresence> {focused && collapsed && ( <motion.div className="absolute top-0.5 left-0 h-5 w-[1.5px] -ml-px" initial={false} animate={{ x: caretX }} transition={{ duration: CARET_SLIDE, ease: "easeOut" }} > {/* Keyed on position so it stays solid while moving and only starts blinking once the caret comes to rest. */} <motion.div key={caretX} className="h-full w-full rounded-full bg-foreground" animate={{ opacity: [1, 1, 0, 0] }} transition={{ duration: 1.06, times: [0, 0.5, 0.5, 1], ease: "linear", repeat: Infinity, }} /> </motion.div> )} </div> </div> </div> </div> ); } // One pen stroke. It is drawn along its path from `delay` for `duration` // seconds. Until the pen reaches it the stroke is fully hidden: a zero-length // round-capped stroke would otherwise paint a dot, so opacity is derived from // the draw progress itself instead of a separately timed fade. const PenStroke = ({ d, timing }: { d: string; timing?: { delay: number; duration: number } | null }) => { const progress = useMotionValue(timing ? 0 : 1); const opacity = useTransform(progress, (v) => (v > 0 ? 1 : 0)); useEffect(() => { if (!timing) return; const controls = animate(progress, 1, { delay: timing.delay, duration: timing.duration, ease: "linear" }); return () => controls.stop(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return <motion.path d={d} style={{ pathLength: progress, opacity }} />; }; // Static ink, for the cursive placeholder. const InkLine = ({ text, className }: { text: string; className?: string }) => { const chars = graphemes(text); const offsets = chars.map((_, i) => inkWidth(chars.slice(0, i))); return ( <g className={className}> {chars.map((ch, i) => ( <g key={i} transform={`translate(${offsets[i]} 0)`}> {inkGlyph(ch).strokes.map((stroke, k) => ( <path key={k} d={stroke.d} /> ))} </g> ))} </g> ); };
Adds the files to
components/uibrik/type-fighter and installs @chenglou/pretext and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/type-fighter.Props
TypeFighter- mode
- Type "comet" | "cursive"
- "comet"
- Typing style: flung comets or a pen.
- placeholder
- Type string
- "Type something"
- flyDuration
- Type number
- 0.3
- How long a comet glyph takes to fly in.
- sparkCount
- Type number
- 14
- Sparks per impact.
- penSpeed
- Type number
- 380
- Cursive pen speed, px per second.