Toolkitly

How to scale a UI panel without stretching the border

You draw a 24×24 dialogue box with a crisp two-pixel border. In the game it is 200 pixels wide and the border is fat on one side, thin on the other, and nobody can say exactly what went wrong. Here is what went wrong.

1. What stretching actually does to a border

Scaling an image means deciding, for every pixel of the output, which pixel of the input it should show. Stretch a 24-pixel-wide panel to 200 and the mapping is one output pixel per 24 ÷ 200 = 0.12 input pixels — so each input pixel covers eight or nine output pixels, and which of the two it gets depends on where it lands in the rounding.

For the middle of a panel that is harmless. For the border it is the whole problem. A two-pixel border becomes sixteen pixels on one edge and eighteen on the other, because the two source pixels rounded differently. Nothing is blurred — with nearest-neighbour sampling every pixel is still hard-edged — but the frame is visibly lopsided, and at a glance it reads as sloppy artwork rather than as a scaling artefact.

Turning smoothing on does not fix it; it trades an uneven border for a soft one. The fix is to stop scaling the border at all.

2. Nine-slice: hold the corners still

Cut the panel with two vertical lines and two horizontal ones. That gives nine regions, and each is treated differently when the panel is resized:

The four corners are copied at their original size and never scaled. The top and bottom edges grow horizontally only. The left and right edges grow vertically only. The middle grows both ways.

So the border keeps its thickness — because the parts of it that define that thickness are inside the corners and edges, which only ever grow along the direction the border runs.

The whole configuration is four numbers: the distance from each edge of the image to its slice line. Every engine calls them something different and every engine wants the same four values.

3. Where to put the lines

Just past whatever must not deform. If the corner has a decorative flourish three pixels in, the line goes at four. If the border is a plain two-pixel frame with a rounded corner, one pixel past the curve is enough.

Not further than that. Everything inside the lines is what repeats, and a middle region that is too small has to repeat many times to fill a large panel — which is fine for a flat fill and obvious for a pattern.

Mind what the middle contains. If the middle has a texture with a period — a dot grid every four pixels, a diagonal hatch — then the middle region should be a whole number of periods wide and tall. Otherwise the pattern jumps at every tile boundary, and the seams form a grid across the panel that is more distracting than the texture was worth.

Bars are a special case worth knowing: a health bar with rounded caps is a nine-slice with the top and bottom insets set to zero. Only the left and right stay fixed, and the middle grows in one direction. The same four numbers describe it.

4. The setting all three engines get wrong

Nine-slice solves which regions scale. It does not by itself say how the edges and middle fill their new space, and the default answer is stretch — in Godot, in Unity, and in CSS.

Stretching the middle has the same problem as stretching the whole panel, just confined to a smaller area. If the middle is a flat colour nobody notices. If it has any texture at all, that texture is resampled at a fractional ratio and comes out uneven.

Tiling repeats the middle at its original scale instead. Every pixel is the size it was drawn, always. Set it explicitly:

GodotNinePatchRect, Axis Stretch Mode Horizontal and Vertical both set to Tile. The default is Stretch.

Unity — an Image with Image Type Tiled rather than Sliced. Sliced is the nine-slice with stretching; Tiled is the nine-slice with tiling.

CSSborder-image-repeat: repeat (or round, which scales slightly so a whole number of tiles fits). stretch is the initial value.

And in all three: the texture filter has to be nearest, or the engine will blur between pixels regardless of everything above.

5. Three ways to deliver it

The four numbers. The normal answer. The engine does the slicing at draw time, one small texture serves every size, and changing the panel later means editing one image. Godot names its margins patch_margin_left and so on; Unity's Sprite Editor takes a Border whose order is left, bottom, right, top — not the order anything else uses, and swapping the middle two gives you a panel that looks nearly right until the top and bottom margins differ.

A .9.png. Android and LibGDX read the slice positions out of the image itself: a one-pixel border where black marks the stretchable columns and rows. Convenient because the numbers travel with the file, fussy because the rest of that border must be perfectly transparent — one stray pixel and the file is rejected as malformed.

A pre-rendered PNG. Sometimes the panel is only ever one size. Then the simplest thing is to render it at that size once and ship a flat image, with no runtime slicing at all. If you go this way, scale by whole factors with Pixel Art Resizer rather than letting an editor resample it.

Common problems

The border is thicker on one side than the other

The middle is being stretched, not tiled. Set Godot's Axis Stretch Mode to Tile, Unity's Image Type to Tiled, or CSS's border-image-repeat to repeat.

The pattern in the middle has visible seams

The middle region is not a whole number of pattern periods. Move the slice lines so it is, or use round instead of repeat in CSS.

The corner decoration is cut in half

A slice line is inside the decoration rather than past it. Move it outward until the whole flourish sits in the corner region.

Everything is blurry even though tiling is on

That is the texture filter, not the nine-slice. Nearest in Godot, Point in Unity, image-rendering: pixelated in CSS.

In Unity the top and bottom margins are swapped

Unity's border is left, bottom, right, top. You entered it as left, top, right, bottom.

The .9.png is rejected as malformed

Something in the one-pixel guide border is neither pure black nor fully transparent — an anti-aliased pixel from drawing it by hand is the usual cause. Generate the border rather than painting it.

Slice a panel now