{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "perspective-text", "type": "registry:ui", "title": "Perspective Text", "description": "A display headline projected onto the ground plane. Letters read wide at the cap line and taper to the baseline, fanning out from a vanishing point below — painted-on-the-road lettering, in plain CSS transforms.", "files": [ { "path": "registry/ruixenui/perspective-text.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PerspectiveTextProps\n extends Omit, \"children\"> {\n /** The headline. Set on an arc, so it stays real selectable text. */\n text: string;\n /** How deep the arc bows, as a fraction of the headline's width. 0 is a flat baseline. */\n curve?: number;\n /** Degrees the plane tips toward the viewer, giving the letters their taper. 0 is flat type. */\n tilt?: number;\n /** Vertical scale applied before the tilt, undoing its foreshortening and then some. */\n stretch?: number;\n /** Typeface for the headline. Heavy condensed faces hold up best under the tilt. */\n fontFamily?: string;\n /** Element wrapping the headline. Use a heading on a real page. */\n as?: React.ElementType;\n}\n\n/** Font size the geometry is authored at. One em === this many user units. */\nconst EM = 100;\n\n/**\n * Average glyph advance for a heavy condensed face, measured from Impact\n * (0.502 em/char). Only sizes the box — the type is never scaled to fit it.\n */\nconst ADVANCE = 0.5;\n\n/** Cap height and descender as fractions of the em, for sizing the viewBox. */\nconst CAP = 0.72;\nconst DESCENDER = 0.25;\n\n/** Viewer distance for the projection. In em, so the warp holds at any headline size. */\nconst VIEWER_DISTANCE = 2.8;\n\n/** How far past the estimated word the path runs, so wide faces still fit on it. */\nconst OVERRUN = 3;\n\n/** Ceiling on the arc's half-angle, keeping it short of wrapping the full circle. */\nconst MAX_HALF_ANGLE = 2.9;\n\n/** Impact is the one heavy condensed face that ships on both macOS and Windows. */\nconst DISPLAY_STACK =\n 'Impact, Haettenschweiler, \"Arial Narrow Bold\", \"Franklin Gothic Bold\", sans-serif';\n\nexport function PerspectiveText({\n text,\n as: Component = \"span\",\n className,\n curve = 0.08,\n fontFamily = DISPLAY_STACK,\n stretch = 1.35,\n style,\n tilt = 30,\n ...props\n}: PerspectiveTextProps) {\n // Ids must survive into an href fragment, and useId's colons do not.\n const pathId = `arc-${React.useId().replace(/:/g, \"\")}`;\n\n const geometry = React.useMemo(() => {\n const width = Math.max(1, text.length) * ADVANCE * EM;\n const sagitta = Math.max(curve, 0) * width;\n\n // Baseline sits low enough that the arc's apex still clears the top edge.\n const baseline = sagitta + CAP * EM;\n // Reserving descender room a word never uses leaves dead space at the\n // bottom of the box, which reads as the headline sitting too high once the\n // box is centred. Only pay for it when something actually descends.\n // ponytail: Latin descenders only — widen the class for other scripts.\n const height = baseline + (/[gjpqy]/.test(text) ? DESCENDER * EM : 0);\n\n // The path runs well past the box on both sides. A glyph that falls off the\n // end of a textPath is not drawn at all, so a face wider than the estimate\n // would silently lose its first and last letters. Running long costs\n // nothing — the path is never stroked — and the text is centred on it.\n if (sagitta < EM * 0.002) {\n const half = width * OVERRUN;\n return {\n d: `M ${width / 2 - half} ${baseline} L ${width / 2 + half} ${baseline}`,\n height,\n width,\n };\n }\n\n // Radius of the circle through both chord ends and the apex.\n const radius = (width * width + 4 * sagitta * sagitta) / (8 * sagitta);\n const centerY = CAP * EM + radius;\n\n // Half-angle the estimated word occupies, then the same circle carried\n // further round so anything wider still lands on the path. Curvature is set\n // by the radius alone, so the bow is identical whatever the extension.\n const nominal = Math.asin(Math.min(1, width / 2 / radius));\n const half = Math.min(nominal * OVERRUN, MAX_HALF_ANGLE);\n const dx = radius * Math.sin(half);\n const dy = centerY - radius * Math.cos(half);\n const largeArc = 2 * half > Math.PI ? 1 : 0;\n const r = radius.toFixed(3);\n\n return {\n d: `M ${(width / 2 - dx).toFixed(3)} ${dy.toFixed(3)} A ${r} ${r} 0 ${largeArc} 1 ${(width / 2 + dx).toFixed(3)} ${dy.toFixed(3)}`,\n height,\n width,\n };\n }, [curve, text]);\n\n return (\n \n \n \n {/*\n Anchoring the middle of the string to the middle of the arc centres the\n headline by construction — for any typeface, any string. Pinning it to\n the path's full length instead would force every glyph to match a width\n guessed from the character count, which distorts the letterforms by\n however far that guess is off.\n */}\n \n \n {text}\n \n \n \n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/perspective-text.tsx" } ] }