SVG UI Tricks: ViewBox, Strokes, and Fills
This article focuses on three practical inline SVG techniques used in game UI: dynamic visual variants through fill and stroke , camera-like minimap control through viewBox , and progress/connection effects through stroke dashing .
The key principle is to keep SVGs inline only when you need internal node control. For static artwork, keep using external SVGs as covered in the previous article.
Why Inline SVG Matters for Stateful UI
Section titled “Why Inline SVG Matters for Stateful UI”When the same icon or shape appears in multiple gameplay states, exporting a new image for each state quickly creates asset duplication. Inline SVG lets you keep one geometry definition and apply different visual states with classes, variables, and data attributes.
This is valuable for:
- Rarity tiers, locked/unlocked states, cooldown overlays, and quest-state highlights.
- Reusable widgets, such as minimaps and radial meters, where geometry remains constant but appearance changes each frame.
- UI systems that need rapid iteration without re-exporting large icon sets.
Generating Variations with Fills and Strokes
Section titled “Generating Variations with Fills and Strokes”A typical use case for inline SVG is handling item rarity. For example, in a game’s UI, you may need the same weapon icon to appear in several different states depending on gameplay needs:
- neutral (common)
- colored (rare)
- colored and emphasized (legendary)
- disabled (locked)
Exporting separate images for each variation is possible, but leads to more files to manage and slows down future updates.
Inline SVG gives you a more maintainable option: keep the geometry in the markup, then let CSS classes control the visual state. The important part is that each meaningful SVG shape has a class.
In this example, the blade, guard, and spark can all be styled independently.
<button class="weapon-button" id="preview-button" type="button"> <svg class="weapon-icon" viewBox="0 0 64 64"> <path class="weapon-icon__blade" d="M48 4 L58 14 L25 47 L15 37 Z" /> <path class="weapon-icon__guard" d="M12 42 L22 32 L32 42 L22 52 Z" /> <path class="weapon-icon__spark" d="M49 8 L52 15 L59 18 L52 21 L49 28 L46 21 L39 18 L46 15 Z" /> </svg>
<div class="weapon-label">Common Sword</div> <div class="state-label">Base</div></button>The SVG itself does not know what rarity it represents. It only exposes named pieces of geometry. The base .weapon-button class defines the default
variables, and the SVG paths read from those variables.
.weapon-button { --blade-fill: #8b8b8b; /* The main blade color (default: neutral weapon) */ --blade-stroke: transparent; /* Optional outline for blade (invisible in base state) */ --guard-fill: #373330; /* The color of the sword guard */ --spark-fill: transparent; /* Spark highlight (revealed for legendary, hidden by default) */ --spark-opacity: 0; /* Spark visibility */}
.weapon-icon { width: 58px; height: 58px;}
.weapon-icon__blade { fill: var(--blade-fill); stroke: var(--blade-stroke); stroke-width: 1.5; transition: fill 140ms ease, stroke 140ms ease;}
.weapon-icon__guard { fill: var(--guard-fill); transition: fill 140ms ease;}
.weapon-icon__spark { fill: var(--spark-fill); opacity: var(--spark-opacity); transition: fill 140ms ease, opacity 140ms ease;}Once the base styles exist, rarity variations only need to apply classes that override variables:
In this example, there are three modifier states beyond the base style:
- The rare state changes the blade color and adds a stroke.
- The legendary state keeps the stronger blade treatment and reveals the spark path.
- The disabled state desaturates the icon by changing the blade and guard values.
.weapon-button--rare { /* Only change blade and add a stroke to simulate shine */ --blade-fill: #f2a34e; --blade-stroke: #ffd89d;}
.weapon-button--legendary { /* Keep the stronger blade treatment and reveal the spark path */ --blade-fill: #4ef2d9; --blade-stroke: #c8fff6; --spark-fill: #c8fff6; --spark-opacity: 1;}
.weapon-button--disabled { /* Desaturate the icon by changing the blade and guard values */ --blade-fill: #3f3f3f; --guard-fill: #2a2a2a;}Applying a variation is then just a class change on the base element. The SVG paths update automatically because their fill, stroke, and opacity
are connected to the variables.
An example change of the state could look like this:
const button = document.getElementById("preview-button");
function applyRarity(rarityClass) { button.classList.remove("weapon-button--rare", "weapon-button--legendary", "weapon-button--disabled");
if (rarityClass) { button.classList.add(rarityClass); }}
applyRarity("weapon-button--legendary");applyRarity(""); /* Return to the base/common state */The entire example looks like this:
Minimap Panning with viewBox
Section titled “Minimap Panning with viewBox”Another useful inline SVG pattern is using viewBox as a camera for maps and minimaps. Instead of moving every map path individually, the SVG keeps
the same UI size and you change which part of its internal coordinate system is visible.
The important distinction is that the SVG has two different sizes:
- The CSS size, which controls how large the map appears in your UI.
- The
viewBoxsize, which controls which internal SVG coordinates are currently visible.
The viewBox attribute is made of four numbers:
viewBox="x y width height"xandydefine the top-left position of the visible window inside the SVG map.widthandheightdefine how much of the SVG map is visible.- Increasing
xorypans across the map. - Reducing
widthandheightzooms in because the same screen area now shows a smaller part of the SVG coordinate space.
Defining the Map Coordinate Space
Section titled “Defining the Map Coordinate Space”Set up an SVG with a viewBox so we can define the coordinate space and the initial visible area. This gives us a baseline before we pan and zoom by
changing the viewBox values:
<div class="map-frame"> <svg id="world-map" class="world-map" viewBox="0 0 400 400"> <!-- Map content goes here --> </svg></div>From here, all map movement and zooming comes from updating viewBox.
The viewBox="0 0 400 400" value means:
- Start viewing the SVG from coordinate
0, 0. - Show
400units horizontally. - Show
400units vertically.
.map-frame { width: 56vh; height: 56vh; border: 0.12vh solid #2a3b49; border-radius: 0.8vh; overflow: hidden; background: #0f151c;}
.world-map { width: 100%; height: 100%;}Here the map is sized with vh so it scales with the game viewport. You could use px, vh, vmax, or a layout-specific variable. The viewBox
logic would stay the same because viewBox does not use CSS units. It uses the SVG’s internal coordinate system.
In other words, width: 56vh and height: 56vh only define the final UI box on screen. The internal map is still 400x400 SVG units, and every
point, road, marker, and region is interpreted inside that coordinate space. When the UI box grows or shrinks, the SVG content scales
proportionally to fit that box.
Building the viewBox Camera
Section titled “Building the viewBox Camera”Treat the viewBox as a rectangular camera window moving over the SVG map:
panXcontrols the camera’s left edge.panYcontrols the camera’s top edge.visibleWidthcontrols how much of the map is visible horizontally.visibleHeightcontrols how much of the map is visible vertically.
Start by storing the full map size and the current camera state:
const map = document.getElementById("world-map");
const MAP_WIDTH = 400;const MAP_HEIGHT = 400;
let zoom = 1;let panX = 0;let panY = 0;The zoom value does not scale the SVG element directly. Instead, it changes how large the visible window is. At zoom = 1, the visible window is the
full 400x400 map. At zoom = 2, the visible window becomes 200x200, so the same UI area displays a smaller portion of the map and appears zoomed
in.
Notice that this calculation never reads the CSS size of .map-frame. Whether the map is rendered as 56vh, or any other responsive size, the camera
math still operates in 400x400 SVG coordinates.
const visibleWidth = MAP_WIDTH / zoom;const visibleHeight = MAP_HEIGHT / zoom;After calculating the camera size, clamp its position (bounds check). Without this clamp, the camera could move past the edge of the SVG and reveal empty space.
const x = Math.max(0, Math.min(panX, MAP_WIDTH - visibleWidth));const y = Math.max(0, Math.min(panY, MAP_HEIGHT - visibleHeight));Finally, write the four camera values back into the SVG:
map.setAttribute("viewBox", `${x} ${y} ${visibleWidth} ${visibleHeight}`);Combined into a function, the camera update looks like this:
function applyViewBox() { const visibleWidth = MAP_WIDTH / zoom; const visibleHeight = MAP_HEIGHT / zoom;
const x = Math.max(0, Math.min(panX, MAP_WIDTH - visibleWidth)); const y = Math.max(0, Math.min(panY, MAP_HEIGHT - visibleHeight));
map.setAttribute("viewBox", `${x} ${y} ${visibleWidth} ${visibleHeight}`);}
applyViewBox();Moving the camera with mouse input
Section titled “Moving the camera with mouse input”A common pattern is to use mouse input to move the camera. This is done by tracking the mouse position and converting it into SVG coordinates.
For mouse-based map navigation, connect dragging to panX / panY and the mouse wheel to zoom.
let isDragging = false;let lastMouseX = 0;let lastMouseY = 0;
map.addEventListener("mousedown", (event) => { isDragging = true; lastMouseX = event.clientX; lastMouseY = event.clientY;});
map.addEventListener("mousemove", (event) => { if (!isDragging) return;
const visibleWidth = MAP_WIDTH / zoom; const visibleHeight = MAP_HEIGHT / zoom; const deltaX = event.clientX - lastMouseX; const deltaY = event.clientY - lastMouseY;
// Convert screen movement into SVG coordinate movement. panX -= deltaX * (visibleWidth / map.clientWidth); panY -= deltaY * (visibleHeight / map.clientHeight);
lastMouseX = event.clientX; lastMouseY = event.clientY;
applyViewBox();});
map.addEventListener("mouseup", () => { isDragging = false;});
map.addEventListener("wheel", (event) => { event.preventDefault();
zoom += event.deltaY < 0 ? 0.2 : -0.2; zoom = Math.max(1, Math.min(zoom, 8));
applyViewBox();});The end result looks like this:
Progress Bars and Connections with Stroke Dashing
Section titled “Progress Bars and Connections with Stroke Dashing”Some UI elements need to show partial progress along a fixed shape. A loading bar can fill from left to right, a circular meter can reveal around its rim, or a skill-tree connector can light up as the next node unlocks. Exporting separate images for each step is not practical, because the geometry is the same and only the visible portion changes.
Before explaining the progress logic, let’s overview the SVG properties involved:
ddefines the shape of the path element . For a straight progress bar, this can be a simple move-and-line command such asM 24 40 L 276 40.pathLengthdefines a normalized length for the path. In this example,pathLength="100"lets the dash values behave like percentages instead of using the path’s authored coordinate length.strokedefines the visible color of the path outline. In this pattern, the track usually uses a dim color and the fill path uses the active color.stroke-widthdefines how thick the outlined path appears.- stroke-dasharray defines the dash pattern used to draw the stroke. If you set it to the same value as the path length, the path becomes one long dash.
- stroke-dashoffset shifts that dash along the path. This is the value you change to hide or reveal the colored stroke.
These properties work together by turning one path into a controllable reveal. The foreground path is still fully present in the SVG, but the dash
offset hides part of its stroke. At progress = 0, the offset equals the full path length and the colored stroke is hidden. At progress = 1, the
offset is 0 and the full stroke is visible.
Creating a progress bar
Section titled “Creating a progress bar”Start with two paths using the same d value. The first path is the dim track. The second path is the colored fill that will be revealed by changing
its dash offset:
<svg class="energy-meter" viewBox="0 0 300 80" aria-label="Energy charge"> <path class="meter__track" d="M 24 40 L 276 40" pathLength="100" />
<path id="energy-fill" class="meter__fill" d="M 24 40 L 276 40" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100" /></svg>The CSS keeps both shapes visually aligned. The foreground path uses the same geometry as the track, but its visible length is controlled by the dash properties:
.energy-meter { width: 300px; height: 80px;}
.meter__track,.meter__fill { fill: none; stroke-width: 10; stroke-linecap: round;}
.meter__track { stroke: rgba(255, 255, 255, 0.12);}
.meter__fill { stroke: #60c8ff; stroke-dasharray: 100; /* Match the normalized path length. */ stroke-dashoffset: 100; /* Start fully hidden. */}Once the SVG structure exists, the update code only needs to convert a normalized progress value into a dash offset. This is also where the stroke can switch from an idle color to an active color:
const fillPath = document.getElementById("energy-fill");
const pathLength = 100;const activeColor = "#60c8ff";const idleColor = "rgba(255, 255, 255, 0.12)";
function setProgress(value) { const progress = Math.max(0, Math.min(value, 1)); const offset = pathLength * (1 - progress);
fillPath.setAttribute("stroke-dashoffset", String(offset)); fillPath.setAttribute("stroke", progress > 0 ? activeColor : idleColor);}
setProgress(0.65);Creating a skill tree node connection
Section titled “Creating a skill tree node connection”The same idea scales to a skill tree without changing the rendering technique. Each connection can store a path definition and a length, or normalize
the connector with pathLength="100" if you want to keep the progress math percentage-based:
const edge = { d: "M 200 50 L 90 155", length: 100,};Render a dim ghost path underneath, then render a second path with the same d value on top. The game UI logic decides whether a node is locked,
unlocking, or complete, but the SVG layer still only needs the same two values:
const dashOffset = edge.length * (1 - progress);
edgePath.setAttribute("stroke-dasharray", String(edge.length));edgePath.setAttribute("stroke-dashoffset", String(dashOffset));edgePath.setAttribute("stroke", progress > 0 ? nodeColor : "rgba(255,255,255,0.07)");The end result can look like this:
Creating a circular progress bar
Section titled “Creating a circular progress bar”This dash pattern is most useful when the shape is not a plain rectangle. A circular health meter, a curved cast bar, a route line on a map can all use the same offset logic. The geometry can be straight, curved, or circular, while the progress value remains a single number.
As a matter of fact, this is exactly how our <Progress.Circle /> component in Gameface UI works.
<svg class={styles["circle-svg"]} viewBox="0 0 120 120"> {/* The outline path is the same as the fill path, but it is not filled. */} <path d={PATH_DATA} class={outlineClasses()} style={outlineToken()?.style} /> <path // The fill path is the same as the outline path, but it is filled. d={PATH_DATA} class={fillClasses()} style={fillToken()?.style} pathLength="100" stroke-dasharray="100" stroke-linecap={fillToken()?.shape === "round" ? "round" : "square"} stroke-dashoffset={clampProgress(100 - props.progress)} /></svg>© 2026 Coherent Labs. All rights reserved.