text-on-mesh warping
CSS transforms can fake 3D. wrapType does it properly — distributing real DOM text elements across the surface of a sphere, cylinder, torus, flat plane, waving flag, or stool using Three.js’s CSS3DRenderer. Variable fonts, CSS animations, and every other Liiift tool compose naturally because the characters are actual HTML, not canvas pixels.
Interactive demo
How it works
Real DOM on every surface
Three.js’s CSS3DRenderer places HTML elements in 3D space using CSS perspective and matrix3d transforms. Each character is its own element, oriented so its face points along the surface normal at that point.
Surface geometry, not a canvas
Character positions are computed analytically from the shape equations — no UV unwrapping or texture atlases. Spheres use latitude-band distribution scaled by circumference; cylinders and tori follow arc-length parameterisation.
Four fill modes
Cover tiles the entire surface. Flow runs a single band around the circumference or equator. Full-width sets the text once around the sphere’s equator, scaled to fit. Full-height runs one character per line down the meridian. Rows are justified, so the text meets itself without a seam. Every glyph faces outward and reads left to right.
Composes with everything
Because characters are real DOM, font-variation-settings, CSS animations, hover states, and other Liiift tools all work without any special integration. Screen readers and find-in-page get the text once; the 3D letters are hidden from them.
Custom meshes
Drop any .glb, .gltf, or .obj file onto the demo above. wrapType samples the surface with MeshSurfaceSampler, orients each character along the local normal, then auto-scales to fit the scene radius.
Prefer GLB over OBJ
GLB is a self-contained binary bundle — no separate .mtl or texture files. Normals, materials, and the scene hierarchy are all embedded. OBJ works but ships without normals by default; wrapType computes them automatically when absent.
One mesh, merged geometry
wrapType picks the first Mesh it finds in the scene graph. For multi-part models, merge all objects into a single mesh before exporting so the full surface is sampled, not just one component.
Keep polygon count reasonable
5,000–50,000 triangles is plenty. Higher counts do not improve text placement quality — MeshSurfaceSampler distributes characters area-proportionally regardless. Lighter meshes load and parse faster in the browser.
Scale and coordinate system
GLTF and OBJ both use Y-up, right-handed axes. wrapType auto-fits the bounding box to its radius option, so absolute model scale does not matter. Recalculate normals in your DCC tool after any non-uniform scaling to keep character orientation correct.
Usage
TypeScript + React · Vanilla JS
React component
import { WrapTypeScene } from '@overpunch/wraptype'
<WrapTypeScene
text="Typography is the art and technique of arranging type"
shape="sphere"
fill="cover"
fontSize={13}
color="rgba(220,210,255,0.85)"
autoRotate
style={{ width: '100%', height: '500px' }}
/>React hook
import { useWrapType } from '@overpunch/wraptype'
const { ref } = useWrapType({
text: 'Typography is the art and technique of arranging type',
shape: 'cylinder',
fill: 'flow',
})
<div ref={ref} style={{ width: '100%', height: '500px' }} />Vanilla JS
import { createWrapScene } from '@overpunch/wraptype/core'
const container = document.getElementById('scene')
// null positions: the scene measures each character and lays the text out itself
const scene = createWrapScene(container, null, {
text: 'Typography is the art and technique of arranging type',
shape: 'torus',
fill: 'cover',
autoRotate: true,
})
// Later:
scene.destroy()Custom mesh (Vanilla JS)
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import type { Mesh } from 'three'
import { getCharPositionsFromMesh, createWrapScene } from '@overpunch/wraptype/core'
const loader = new GLTFLoader()
loader.load('/model.glb', (gltf) => {
// The first mesh in the file (merge multi-part models before exporting)
const mesh = gltf.scene.getObjectByProperty('isMesh', true) as Mesh | undefined
if (!mesh) return
const container = document.getElementById('scene')!
const positions = getCharPositionsFromMesh(
mesh,
'Typography on any surface',
{ radius: 300 },
250, // sample count
)
const scene = createWrapScene(container, positions, {
text: 'Typography on any surface',
autoRotate: true,
})
})Options
| Option | Default | Description |
|---|---|---|
| text | — | The string to distribute across the surface. Repeats to fill. |
| shape | 'sphere' | 'sphere' · 'cylinder' · 'torus' · 'plane' · 'stool' · 'flag' |
| mode | 'surface' | 'surface' — characters sit on the geometry. ('silhouette' is reserved and not implemented yet; it falls back to 'surface' with a warning.) |
| fill | 'cover' | 'cover' · 'flow' · 'full-width' · 'full-height'. ('pattern' is reserved; it falls back to 'cover'.) |
| fontSize | 14 | Character font size in px. |
| fontFamily | — | CSS font-family override applied to each character span. |
| fontWeight | 'normal' | CSS font-weight applied to each character span. |
| color | 'white' | CSS color applied to every character element. |
| radius | 300 | Surface radius in scene units. |
| height | radius × 2 | Cylinder or plane height in scene units. |
| autoRotate | false | Continuously rotate the scene. |
| autoRotateSpeed | 1.0 | Rotation speed multiplier. |
| camera | 'orbit' | 'orbit' (drag to rotate, scroll to zoom) · 'fixed' |
| zoom | true | Wheel / pinch zoom on the orbit camera. Set false so the mouse wheel scrolls the page over the scene. |
| showBackfaces | false | Show characters on the far side, seen through the surface (mirrored). Hidden by default. |
| cameraPosition | [0, 0, 700] | Initial camera position [x, y, z] in scene units. |
| charAdvanceRatio | 0.62 | Fraction of fontSize used as a character's advance when it has no measured width (the scene and the React components measure widths for you). |
| charWidthMap | measured | Advance width per character in px (see measureCharWidths). |
| lineHeightRatio | 1.4 | Line height multiplier relative to fontSize. |
| repeat | true | When false, text is placed exactly once without tiling; text that doesn't fit is left out with a warning. |
| characterCurve | 0 | Bend characters to follow surface curvature. 0 = flat, 1 = full bend. |