Skip to content
SiteEmail

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.

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.

rarity-icon.html
<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.

rarity-icon.css
.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.
rarity-variations.css
.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:

rarity-toggle.js
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:

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 viewBox size, which controls which internal SVG coordinates are currently visible.

The viewBox attribute is made of four numbers:

viewBox="x y width height"
  • x and y define the top-left position of the visible window inside the SVG map.
  • width and height define how much of the SVG map is visible.
  • Increasing x or y pans across the map.
  • Reducing width and height zooms in because the same screen area now shows a smaller part of the SVG 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:

minimap.html
<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 400 units horizontally.
  • Show 400 units vertically.
minimap.css
.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.

Treat the viewBox as a rectangular camera window moving over the SVG map:

  • panX controls the camera’s left edge.
  • panY controls the camera’s top edge.
  • visibleWidth controls how much of the map is visible horizontally.
  • visibleHeight controls how much of the map is visible vertically.

Start by storing the full map size and the current camera state:

minimap.js
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.

minimap-camera-size.js
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.

minimap-camera-clamp.js
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:

minimap-apply-viewbox.js
map.setAttribute("viewBox", `${x} ${y} ${visibleWidth} ${visibleHeight}`);

Combined into a function, the camera update looks like this:

minimap-camera.js
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();

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.

mouse-map-controls.js
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:

  • d defines the shape of the path element . For a straight progress bar, this can be a simple move-and-line command such as M 24 40 L 276 40.
  • pathLength defines 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.
  • stroke defines 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-width defines 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.

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:

stroke-progress.html
<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:

stroke-progress.css
.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:

stroke-progress.js
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);

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:

skill-tree-edge.js
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:

skill-tree-progress.js
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:

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.

Progress.Circle.tsx
<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>