Create stunning 3D text with custom fonts in Three.js
•8 min read
When doing anything 3D on the web, the first library that comes up in a developer’s mind is Three.js. Three.js provides high-level abstractions to draw 3D graphics using WebGL in the browser. I have been playing around with it quite a bit recently for 3D text rendering and realized that using custom font faces is a pain, especially when you want a simple way to render custom fonts from CDNs like Google Fonts, Fontsource. There’s very little documentation on this topic online and I hope this article helps in filling that gap.
Elements of 3D text rendering
Let’s take a look at the different pieces involved at high level for rendering Text in Three.js.
TextGeometry
Three.js exposes a TextGeometry
TextGeometry
has various options to customize font face, size, thickness, bevel, etc. The example usage of this class in the
documentation shows the following code snippet.
1const loader = new FontLoader();23loader.load('fonts/helvetiker_regular.typeface.json', function (font) {4const geometry = new TextGeometry('Hello three.js!', {5font: font,6size: 80,7height: 5,8// other options...9});10});
FontLoader
Notice the FontLoader
class in the above snippet? It is responsible for creating a
FontLoader
TextGeometry
to create the text in that font face.
The load()
method of the FontLoader
instance takes a file path / URL to the font file and a callback function which gets called
with the loaded font data. This font data can then be passed to TextGeometry
’s constructor for creating a text geometry in that font.
Mesh
and Material
The text geometry cannot be rendered as is, it has to first be converted to a Mesh
object. A Mesh
holds the geometry definition
and the materials applied to it which is used by the renderer to render that object. In the following example, I assign
a MeshStandardMaterial
import * as THREE from 'three'; import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'; import init from './setup'; const scene = init(); const loader = new FontLoader(); // Loading the JSON font file from CDN. Can be a file path too. loader.load('https://unpkg.com/[email protected]/examples/fonts/helvetiker_regular.typeface.json', (font) => { // Create the text geometry const textGeometry = new TextGeometry('Hello world', { font: font, size: 18, height: 5, curveSegments: 32, bevelEnabled: true, bevelThickness: 0.5, bevelSize: 0.5, bevelSegments: 8, }); // Create a standard material with red color and 50% gloss const material = new THREE.MeshStandardMaterial({ color: 'hotpink', roughness: 0.5 }); // Geometries are attached to meshes so that they get rendered const textMesh = new THREE.Mesh(textGeometry, material); // Update positioning of the text textMesh.position.set(-50, 0, 0); scene.add(textMesh); });
Seems straightforward until you notice something strange in that font file path – 'fonts/helvetiker_regular.typeface.json'
.
See that .json extension? Seems weird right? Most of us are familiar with standard font file formats like TTF, WOFF, WOFF2, etc.
but the FontLoader
only understands font files in .json format! My next question was – how do I convert my existing font files in one of the standard
font file formats mentioned above to the JSON format supported by FontLoader
?
Facetype.js
The documentation of FontLoader
class talks about a tool called
The
- Download all the font source files(in TTF format) from Google Fonts, convert them to static JSON format using the facetype.js tool, then include them in the source code of the project and load them in Three.js. This is not scalable as the Google Fonts library is ever-expanding and each font has various font weights(normal, semi-bold, bold, etc.) and font styles(normal, italicized). Converting each of them to JSON format and serving them from our servers is a waste of resources – time, bandwidth, and even money 💸!
- Use the
from the source code of facetype.js. The JS files can be included in the source code of the project and Google Fonts CDN and APIs can be used to fetch the required TTF font files. Once the file is fetched, the relevant functions can be called(from the JS files that were included from facetype.js) to convert the TTF data to JSON format on-the-fly. Finally, this JSON data can then be loaded in Three.js. This approach is far better than the previous method but the downside to it is that it includes external JS scripts without a package manager. We won’t get any future updates if the scripts change(unless the changes are manually included in our project), and it may be tricky to use it with ES6-imports and TypeScript.JavaScript files
Three.js example files to the rescue!
After spending some time trying to figure all this out, I had almost given up and accepted the second approach mentioned above. I wanted some kind of abstraction that would deal with loading font files in standard formats directly in Three.js without me having to worry about the intermediate steps of converting it to the JSON format.
One of the underrated features of Three.js is the number of official examples the library has. You can experience all these examples at
The
1import * as THREE from 'three';2import { TTFLoader } from 'three/addons/loaders/TTFLoader.js';3import { Font } from 'three/addons/loaders/FontLoader.js';4import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
Three.js already has a TTFLoader
class that can be used to load TTF font files and use them as font faces for TextGeometry
! I did not know that!!
It’s not mentioned anywhere in the documentation for some reason but it seems to be
Final code
With TTFLoader
in our toolbox, the final implementation in a vanilla Three.js project looks far simpler than I had imagined it to be at the start.
import * as THREE from 'three'; import { TTFLoader } from 'three/examples/jsm/loaders/TTFLoader.js' import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; import { Font } from 'three/examples/jsm/loaders/FontLoader.js'; import init from './setup'; const scene = init(); const loader = new TTFLoader(); // Loading the TTF font file from Fontsource CDN. Can also be the link to font file from Google Fonts loader.load('https://api.fontsource.org/v1/fonts/lora/latin-600-italic.ttf', (fontData) => { // Convert the parsed fontData to the format Three.js understands const font = new Font(fontData); // Create the text geometry const textGeometry = new TextGeometry('Hello world', { font: font, size: 18, height: 5, curveSegments: 32, bevelEnabled: true, bevelThickness: 0.5, bevelSize: 0.5, bevelSegments: 8, }); // Create a standard material with red color and 50% gloss const material = new THREE.MeshStandardMaterial({ color: 'hotpink', roughness: 0.5 }); // Geometries are attached to meshes so that they get rendered const textMesh = new THREE.Mesh(textGeometry, material); // Update positioning of the text textMesh.position.set(-50, 0, 0); scene.add(textMesh); });
In React
To use Three.js in React, I recommend installing @react-three/fiber
@react-three/drei
three
Text3D
class which is a wrapper around TextGeometry
to render 3D texts.
It has to be wrapped with React’s <Suspense />
component as the Text3D
component suspends when loading the font. By default, the Text3D
component does
not support TTF font files. It exposes a font
prop that expects the JSON file path / URL as we discussed earlier.
To add support for TTF files, let’s add a wrapper component around the base Text3D
component from drei. This new component takes a url
prop which is the
URL to the TTF font file we’d like to use. While the font is being fetched and loaded using TTFLoader
, let’s suspend the component.
I’ll be using suspend-react
async
functions if any of the dependencies
change and return with the resolved value of the promise.
import { Suspense } from 'react'; import { Canvas } from '@react-three/fiber'; import { Text3D as Text3DBase, OrbitControls, Center } from '@react-three/drei'; import { suspend } from 'suspend-react'; import { TTFLoader } from 'three/examples/jsm/loaders/TTFLoader.js'; /** * This wrapper component builds on top of "Text3D" component exported by @react-three/drei * to support TTF file URLs. */ function Text3D({ url, ...props }) { // Suspend while loading and parsing the TTF file. const font = suspend(() => { const loader = new TTFLoader(); return new Promise((resolve) => { loader.load(url, resolve) }); }, [url]); return ( // Center component centers the text <Center> {/* Pass the loaded font to the component for rendering */} <Text3DBase font={font} {...props} /> </Center> ); } export default function App() { return ( <div className="w-screen h-screen"> <Canvas> <Suspense> <Text3D url="https://api.fontsource.org/v1/fonts/lora/latin-600-italic.ttf" height={0.25} curveSegments={32} bevelEnabled bevelSegments={12} > Hello world {/* Assign a material to the text */} <meshStandardMaterial color="hotpink" roughness={0.5} /> </Text3D> </Suspense> <pointLight position={[0, 2, 2]} /> <ambientLight intensity={0.2} /> <OrbitControls makeDefault /> </Canvas> </div> ); }
Note: If importing
three/addons/loaders/TTFLoader.js
does not work, try importingthree/examples/jsm/loaders/TTFLoader.js
Bonus: Implementing a custom font picker drop-down 🙌
While I’m at it, let me also show you how to implement one of the most common UI elements that are synonymous with text editing – A font family picker drop-down.
I’ll be using the
- Call the
endpoint to fetch all the font faces. This endpoint queries only those fonts which havehttps://api.fontsource.org/v1/fonts?subsets=latin&weights=400
400
(normal) font-weight andlatin
subset. - Render a dropdown with all the fonts returned from the above request as options.
- When the user selects an option, call the
https://api.fontsource.org/v1/fonts/:fontId
endpoint with the relevantfontId
. The result contains a link to the TTF font file that can be loaded withTTFLoader
and rendered in the browser.
import { Suspense, useState, useEffect } from 'react'; import { Canvas } from '@react-three/fiber'; import { Text3D as Text3DBase, OrbitControls, Center } from '@react-three/drei'; import { suspend } from 'suspend-react'; import { TTFLoader } from 'three/examples/jsm/loaders/TTFLoader.js'; function Text3D({ url, ...props }) { const font = suspend(() => { const loader = new TTFLoader(); return new Promise((resolve) => { loader.load(url, resolve) }); }, [url]); return ( <Center> <Text3DBase font={font} {...props} /> </Center> ); } export default function App() { const [fontFamily, setFontFamily] = useState('lora'); const [fontOptions, setFontOptions] = useState([]); const [fontFileUrl, setFontFileUrl] = useState(''); // Fetch all the font families from the API. These will show up in the select menu useEffect(() => { fetch('https://api.fontsource.org/v1/fonts?subsets=latin&weights=400') .then(res => res.json()) .then(setFontOptions); }, []); // Fetch the selected font family's TTF file URL. useEffect(() => { fetch('https://api.fontsource.org/v1/fonts/' + fontFamily) .then(res => res.json()) .then((font) => setFontFileUrl(font.variants['400'].normal.latin.url.ttf)); }, [fontFamily]); return ( <div className="w-screen h-screen"> <div className="absolute top-4 left-4 z-10"> <select value={fontFamily} onChange={(e) => setFontFamily(e.target.value)} className="bg-gray-50 border border-gray-300 text-gray-900 text-sm font-medium rounded focus:ring-blue-500 focus:border-blue-500 p-1" > {fontOptions.map((font) => ( <option value={font.id} key={font.id}> {font.family} </option> ))} </select> </div> <Canvas> <Suspense> {/* Pass the selected font family TTF file URL to the component */} <Text3D url={fontFileUrl} height={0.25} curveSegments={32} bevelEnabled bevelSegments={12} > Hello world <meshStandardMaterial color="hotpink" roughness={0.5} /> </Text3D> </Suspense> <pointLight position={[0, 2, 2]} /> <ambientLight intensity={0.2} /> <OrbitControls makeDefault /> </Canvas> </div> ); }
Some font families don’t render correctly. What’s happening?
You may come across certain font families that don’t render correctly. Either there are extra faces, or the direction of the geometry is reversed. This is perhaps a limitation of how 2D fonts work in a 3D environment. Facetype.js tool has a checkbox to ‘Reverse font direction’ when this happens, but apparently it cannot be automatically detected and applied dynamically. In these cases, it’s better to use alternate font families that don’t suffer from these issues or write text using characters that are immune to this problem. If you know the solution, do let me know.
Conclusion
In this article, I went over how to do 3D text rendering with custom font faces in Three.js. It started as loading fonts in JSON format, to using fonts from popular CDNs like Fontsource and Google Fonts.
Without a doubt, Three.js is an amazing library for doing anything 3D on the browser. While some very specific and niche use cases might not be documented,
it has a gold mine full of examples that can act as a starting point for what you are building. Combined with @react-three/fiber
and @react-three/drei
that 10x the ergonomics of using Three.js with React, 3D has finally become easy for web developers!