1776769836

Drawing Text on the Canvas with React — The API You're Probably Underusing


The HTML5 Canvas API has been around for over a decade, yet it still catches people off guard the first time they use it. The idea is straightforward: the browser hands you a low-level rendering context, and you paint whatever you want, pixel by pixel. In this tutorial we will build an interactive **Text Arranger**, exactly the kind of project from the original 2014 post, but this time using React and the patterns we actually reach for today. By the end you will understand how the Canvas handles text, gradients, and shadows, and you will have a reusable component you can drop into any project. --- ## What the Canvas is and why it still matters The `<canvas>` element is essentially a bitmap that the browser exposes to JavaScript. Unlike SVG, which describes shapes in XML and keeps a manipulable node tree, the Canvas holds no state: when you clear and redraw, the previous image is simply gone. That makes it ideal for animations, games, and anything that demands high rendering throughput. For text specifically, the Canvas gives you fine-grained control over font family, weight, style, alignment, and baseline, along with native support for gradients and patterns via `fillStyle`. You cannot get that level of per-pixel control by applying CSS to a `<div>`. --- ## How the React approach differs The original post attached `addEventListener` directly to DOM elements and called a `drawScreen()` function on every change. In React, the mental model is different: state lives inside the component, and the canvas is redrawn as a side effect whenever that state changes. This maps naturally to `useState` for the controls and `useEffect` for the drawing cycle. ```jsx import { useRef, useState, useEffect } from "react" const CANVAS_WIDTH = 600 const CANVAS_HEIGHT = 300 export default function TextArranger() { const canvasRef = useRef(null) const [text, setText] = useState("Hello, Canvas!") const [fontSize, setFontSize] = useState(50) const [fontFamily, setFontFamily] = useState("serif") const [fontWeight, setFontWeight] = useState("normal") const [fontStyle, setFontStyle] = useState("normal") const [fillType, setFillType] = useState("color") // color | linearGradient | radialGradient const [color1, setColor1] = useState("#e63946") const [color2, setColor2] = useState("#457b9d") const [fillOrStroke, setFillOrStroke] = useState("fill") // fill | stroke | both const [alpha, setAlpha] = useState(1) const [shadowX, setShadowX] = useState(2) const [shadowY, setShadowY] = useState(2) const [shadowBlur, setShadowBlur] = useState(4) const [shadowColor, setShadowColor] = useState("#00000066") const [textAlign, setTextAlign] = useState("center") const [textBaseline, setTextBaseline] = useState("middle") useEffect(() => { const canvas = canvasRef.current const ctx = canvas.getContext("2d") // Clear the previous frame ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT) // Background ctx.globalAlpha = 1 ctx.shadowColor = "transparent" ctx.fillStyle = "#f1faee" ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT) ctx.strokeStyle = "#a8dadc" ctx.lineWidth = 3 ctx.strokeRect(6, 6, CANVAS_WIDTH - 12, CANVAS_HEIGHT - 12) // Text settings ctx.font = `${fontWeight} ${fontStyle} ${fontSize}px ${fontFamily}` ctx.textAlign = textAlign ctx.textBaseline = textBaseline ctx.globalAlpha = alpha // Shadow ctx.shadowColor = shadowColor ctx.shadowOffsetX = shadowX ctx.shadowOffsetY = shadowY ctx.shadowBlur = shadowBlur const x = CANVAS_WIDTH / 2 const y = CANVAS_HEIGHT / 2 const metrics = ctx.measureText(text) const w = metrics.width // Choose the fill style let style if (fillType === "color") { style = color1 } else if (fillType === "linearGradient") { const grad = ctx.createLinearGradient(x - w / 2, y, x + w / 2, y) grad.addColorStop(0, color1) grad.addColorStop(1, color2) style = grad } else if (fillType === "radialGradient") { const grad = ctx.createRadialGradient(x, y, 1, x, y, w / 2) grad.addColorStop(0, color1) grad.addColorStop(1, color2) style = grad } // Draw if (fillOrStroke === "fill" || fillOrStroke === "both") { ctx.fillStyle = style ctx.fillText(text, x, y) } if (fillOrStroke === "stroke" || fillOrStroke === "both") { ctx.strokeStyle = style ctx.lineWidth = 2 ctx.strokeText(text, x, y) } }, [ text, fontSize, fontFamily, fontWeight, fontStyle, fillType, color1, color2, fillOrStroke, alpha, shadowX, shadowY, shadowBlur, shadowColor, textAlign, textBaseline, ]) return ( <div> <canvas ref={canvasRef} width={CANVAS_WIDTH} height={CANVAS_HEIGHT} style={{ display: "block", marginBottom: "1rem" }} /> {/* Controls */} <label> Text <input value={text} onChange={e => setText(e.target.value)} /> </label> <label> Size: {fontSize}px <input type="range" min={10} max={200} value={fontSize} onChange={e => setFontSize(Number(e.target.value))} /> </label> <label> Font family <select value={fontFamily} onChange={e => setFontFamily(e.target.value)}> <option value="serif">Serif</option> <option value="sans-serif">Sans-serif</option> <option value="monospace">Monospace</option> <option value="cursive">Cursive</option> <option value="fantasy">Fantasy</option> </select> </label> <label> Weight <select value={fontWeight} onChange={e => setFontWeight(e.target.value)}> <option value="normal">Normal</option> <option value="bold">Bold</option> <option value="lighter">Lighter</option> </select> </label> <label> Style <select value={fontStyle} onChange={e => setFontStyle(e.target.value)}> <option value="normal">Normal</option> <option value="italic">Italic</option> <option value="oblique">Oblique</option> </select> </label> <label> Fill type <select value={fillType} onChange={e => setFillType(e.target.value)}> <option value="color">Solid color</option> <option value="linearGradient">Linear gradient</option> <option value="radialGradient">Radial gradient</option> </select> </label> <label> Color 1 <input type="color" value={color1} onChange={e => setColor1(e.target.value)} /> </label> <label> Color 2 <input type="color" value={color2} onChange={e => setColor2(e.target.value)} /> </label> <label> Mode <select value={fillOrStroke} onChange={e => setFillOrStroke(e.target.value)}> <option value="fill">Fill</option> <option value="stroke">Stroke</option> <option value="both">Both</option> </select> </label> <label> Alpha: {alpha} <input type="range" min={0} max={1} step={0.01} value={alpha} onChange={e => setAlpha(Number(e.target.value))} /> </label> <label> Shadow X: {shadowX} <input type="range" min={-50} max={50} value={shadowX} onChange={e => setShadowX(Number(e.target.value))} /> </label> <label> Shadow Y: {shadowY} <input type="range" min={-50} max={50} value={shadowY} onChange={e => setShadowY(Number(e.target.value))} /> </label> <label> Shadow blur: {shadowBlur} <input type="range" min={0} max={50} value={shadowBlur} onChange={e => setShadowBlur(Number(e.target.value))} /> </label> <label> Shadow color <input type="color" value={shadowColor.slice(0, 7)} onChange={e => setShadowColor(e.target.value)} /> </label> <label> Horizontal alignment <select value={textAlign} onChange={e => setTextAlign(e.target.value)}> <option value="center">Center</option> <option value="left">Left</option> <option value="right">Right</option> </select> </label> <label> Baseline <select value={textBaseline} onChange={e => setTextBaseline(e.target.value)}> <option value="middle">Middle</option> <option value="top">Top</option> <option value="bottom">Bottom</option> <option value="alphabetic">Alphabetic</option> <option value="hanging">Hanging</option> </select> </label> </div> ) } ``` --- ## Understanding each piece **`useRef` for the canvas** React should not manage the canvas as a controlled element, because what lives inside it is not JSX: it is the result of imperative calls to the drawing API. `useRef` gives us direct access to the DOM node without triggering a re-render. **`useEffect` as the rendering engine** The dependency array at the end of `useEffect` is what connects React's declarative world to the Canvas's imperative one. Every time any piece of state changes, the effect runs from scratch: it clears the canvas and redraws everything. This pattern is intentionally simple. In applications with continuous animations you would use `requestAnimationFrame` inside the effect, but for a static tool like this one, re-rendering on each change event is both sufficient and easy to follow. **Gradients** Creating a gradient on the Canvas always requires absolute coordinates. For a horizontal linear gradient centered on the text, we calculate the text width with `ctx.measureText(text).width` and position the gradient from `x - w/2` to `x + w/2`. For the radial version, we use the same center with different radii to produce a halo effect. Due to character limits,[here is the final part](https://chat-to.dev/post?id=VXFEdE45MUZLSUF2YnZYMmhYMEV).

(0) Comments

Welcome to Chat-to.dev, a space for both novice and experienced programmers to chat about programming and share code in their posts.

About | Privacy | Donate
[2026 © Chat-to.dev]