Skip to content

Button State Generator — Hover, Pressed, Disabled, Focus

Five states from one button, each an ordered list of operations you can see and edit — brightness, contrast, saturation, overlay, offset, outline, alpha. It is arithmetic on pixels: nothing is drawn that was not in the source.

A game button usually needs five images: normal, hover, pressed, disabled and focus. The generator derives them from one PNG with pixel operations you can read and change. Defaults: hover +10 % brightness and +8 % saturation, pressed −10 % brightness and 1 px down, disabled fully desaturated at 50 % alpha, focus a 2 px #3182f6 ring. It exports states/*.png, one packed strip and states.json with every value used. It only changes pixels that are already there; it draws no new artwork.

Drop a UI panel, button, glyph sheet or UI sheet (PNG)
Open the UI Lab
Runs in your browser. Files are not uploaded.
UI Lab: a CC0 UI panel with nine-slice guides and previews at several sizes
The real UI Lab with a CC0 asset (Kenney).
Outputs
  • Nine-sliceMeasured· Pillow
  • Bitmap fontMeasured· independent .fnt parser
  • Button statesMeasured· Pillow
  • Engine importUNVERIFIED

How a state is computed

Each state is an ordered list of operations on the RGBA pixels. First the canvas grows if the state has an outline, then brightness adds the value × 255 to R, G and B, contrast scales around mid grey (128), saturation moves each channel away from or towards the pixel's luma (0.2126 R + 0.7152 G + 0.0722 B), an optional colour overlay is mixed in, the pixels are offset, the outline is drawn as a square dilation of the shape, and finally alpha is multiplied. Fully transparent pixels are never recoloured.

Engines expect these images in fixed slots. Godot 4's TextureButton has texture_normal, texture_hover, texture_pressed, texture_disabled and texture_focused; a disabled button without its own texture shows the normal one, and the focused texture is drawn on top of the base texture. Unity's UI Button with Transition = Sprite Swap takes a normal sprite on its Target Graphic plus Highlighted, Pressed and Disabled sprites.

Two WCAG 2.2 rules are worth knowing even though games are not web pages. Success Criterion 1.4.3 exempts text that is part of an inactive (disabled) component from its contrast minimum, which is why a faded disabled state is acceptable. 2.4.13 Focus Appearance (level AAA) asks for a focus indicator at least as large as a 2 CSS px perimeter of the component with a 3:1 contrast between focused and unfocused pixels; the default ring is 2 px, and #3182f6 against white measures 3.71:1.

Brightness
Added to every channel: +0.1 means +25.5 levels.
Saturation
+0.08 pushes each channel 8 % further from the luma; −1 turns the pixel grey.
Offset
Moves the pixels inside the same canvas; what leaves is dropped, what enters is transparent.
Outline
A ring of the chosen colour and width around the opaque shape; the canvas grows by that width.

Example: one pixel through the five default states

A 40 × 16 button filled with the colour (200, 120, 40). The numbers are what the exported PNGs contain:

state      pixel (R, G, B, A)     size     operations
normal     (200, 120, 40, 255)    40 × 16  none
hover      (231, 145, 58, 255)    40 × 16  brightness +0.1 (+25.5), saturation +0.08
pressed    (174, 94, 14, 255)     40 × 16  brightness −0.1, offset 0,1 → row 0 becomes transparent
disabled   (131, 131, 131, 128)   40 × 16  saturation −1, alpha × 0.5
focus      (200, 120, 40, 255)    44 × 20  canvas +2 px per side, 2 px ring (49, 130, 246)

disabled grey  0.2126 × 200 + 0.7152 × 120 + 0.0722 × 40 = 131.2 → 131;  alpha 255 × 0.5 = 127.5 → 128
hover green    luma 156.7 + (145.5 − 156.7) × 1.08 = 144.6 → 145

The focus image is 4 px wider and taller than the others, and its ring covers the corner pixels too, because the outline is a square dilation. states.json records each state's size and padding so the difference is never a surprise.

What it does

  • Defaults: hover +10 % brightness, pressed −10 % and 1 px down, disabled desaturated at 50 % alpha, focus a 2 px ring.
  • Every operation value is visible and editable per state.
  • Output: one PNG per state, a packed strip and states.json with the rects and the exact operation values.
  • Transparent pixels are never recoloured.

How to do it

  1. Drop the button PNG.
  2. Adjust each state's operations.
  3. Check the five previews side by side.
  4. Export the ZIP.

Use the states in Godot or Unity

  1. Godot 4: add a TextureButton and assign normal.png, hover.png, pressed.png and disabled.png to texture_normal, texture_hover, texture_pressed and texture_disabled.
  2. Godot 4 draws texture_focused over the base texture and its documentation recommends a partly transparent image such as an outline. focus.png contains the whole button, so for that slot erase the button inside the ring in an image editor and keep the 44 × 20 canvas.
  3. Unity UI: on the Button set Transition to Sprite Swap, put normal.png on the Target Graphic's Image and hover.png, pressed.png, disabled.png into Highlighted Sprite, Pressed Sprite and Disabled Sprite.
  4. If the button must resize, give each state PNG the same nine-slice borders in the 9-slice editor before assigning them, because the states carry no borders of their own.

Check the result

  • Every rect in states.json cuts exactly its state out of button-states.png, and states.hover.ops lists the values used.
  • normal.png is the source byte for byte (in Firefox semi-transparent pixels can differ by one alpha-rounding step).
  • Run the game and hover, press and disable the button: nothing jumps, and the pressed state moves by exactly the offset.

Troubleshooting

SymptomLikely causeHow to checkFix
The pressed image loses its bottom rowThe 1 px offset moves the pixels inside the same canvas, so the last row falls off and row 0 becomes transparentCompare the bottom edge of pressed.png with the sourceLeave one transparent row under the art, or set the offset to 0 and move the label in the engine instead
The focused button jumps or looks doubledfocus.png is larger (44 × 20 for a 40 × 16 button) and Godot draws the focus texture over the basestates.json → states.focus.sizeCentre it by −outline px, or use a ring-only overlay as described above
The disabled button almost vanishes on a dark backgroundAlpha 0.5 lets the background show throughLook at it over your real background, not on whiteRaise the disabled alpha (0.7) or keep alpha 1 and only desaturate
Hover is barely visible on a light buttonBrightness is additive and channels already near 255 clipPixel values above about 230 in the sourceUse an overlay colour or contrast for hover instead of brightness

Other ways to do it

OptionWhen it is the better choice
Unity Transition = Color TintThe states only differ in colour: no extra images, but a tint multiplies colours and cannot move pixels (pressed offset) or add a ring.
Draw each state by hand in an art programThe states differ in shape: a bevel that flips, an icon that changes, a glow. Pixel arithmetic cannot draw what is not in the source.

Outputs and how each was checked

"Measured" means the downloaded file was opened again outside the page (Pillow, numpy or an independent parser) and the property was measured. Loading these files in a game engine was not tested.

TargetFilesCheck
Nine-slicenine-slice.json · previews · SETUP.mdMeasured PillowEvery resized preview keeps all four corner blocks unscaled: the same bytes in Chromium; in Firefox semi-transparent pixels can shift by one alpha-rounding step. 0 gaps, 0 overlaps.
Bitmap fontfont.png · font.fnt (BMFont text) · font.jsonMeasured independent .fnt parserThe .fnt was read back by a parser written separately in the test: header and every glyph rect match.
Button statesstates/*.png · strip · states.jsonMeasured PillowNormal equals the source (byte for byte in Chromium; in Firefox semi-transparent colour within one alpha-rounding step); every rect in states.json cuts exactly its state out of the strip.
Engine importGodot NinePatchRect · Unity Sprite border · BMFont importUNVERIFIEDNot run in Godot or Unity.

Evidence

Nine-slice corners unscaled at 100×40, 300×80, 800×200 and 420×120; border suggestion 6·6·6·6 from a run of 12 identical columns; button states: normal equal to the source, disabled grey at alpha 128, focus ring exact; .fnt glyph records equal the JSON; missing glyphs from a .po listed exactly; black on white measured 21:1. Images pass through a browser canvas: Chromium returns the same bytes; Firefox rounds the colour of semi-transparent pixels to one premultiplied step (measured on a Kenney button: up to 4 levels at alpha 63), while alpha and opaque pixels stay exact.

Limits

  • No per-state nine-slice borders: set them on the state PNGs in the 9-slice editor.
  • Engine use was not tested; the slot names above come from the Godot and Unity documentation.
  • No kerning pairs, one font page, no MSDF; the SDF output is Beta.
  • A TTF's cmap is not parsed, so the missing-glyph check needs a .fnt or the Lab's own font.
  • One atlas page; touching elements count as one.
  • Rendering inside Godot or Unity was not run: the engine numbers (patch margins, L-B-R-T border) are documented, not engine-tested.

Versions and sources

Measured in Chromium for docs/UI-LAB.md on a 40 × 16 button: normal is byte-identical to the source, hover is brighter, pressed is offset with the vacated rows cleared, disabled is grey at alpha 128, focus is 44 × 20 with a #3182f6 ring and untouched artwork, and all five rects in states.json cut exactly their state out of the strip. The example values above were recomputed with the same module. Godot and Unity import were not run.

Official documentation used

Questions

Why is the focus image larger?

The focus ring grows the canvas by the outline width; its size is recorded in states.json.

Is the normal state changed?

No — normal is the source byte for byte (checked).

Is it free, and do I need an account?

Everything on these pages is free and works without an account. There is no watermark and no export that is held back.

Are my sprites uploaded?

No. The Studio decodes, packs and exports in this browser tab. Projects are autosaved in the browser (IndexedDB) and can be saved as a .nerulio file.

Does it need Aseprite or an engine plugin installed?

No. .aseprite files are read and written in the browser, and each engine bundle carries what that engine needs (for Godot, the import settings and scene; for Unity and the Godot TileSet, a small editor script).