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.

- 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 → 145The 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
- Drop the button PNG.
- Adjust each state's operations.
- Check the five previews side by side.
- Export the ZIP.
Use the states in Godot or Unity
- Godot 4: add a
TextureButtonand assignnormal.png,hover.png,pressed.pnganddisabled.pngtotexture_normal,texture_hover,texture_pressedandtexture_disabled. - Godot 4 draws
texture_focusedover the base texture and its documentation recommends a partly transparent image such as an outline.focus.pngcontains the whole button, so for that slot erase the button inside the ring in an image editor and keep the 44 × 20 canvas. - Unity UI: on the Button set Transition to Sprite Swap, put
normal.pngon the Target Graphic's Image andhover.png,pressed.png,disabled.pnginto Highlighted Sprite, Pressed Sprite and Disabled Sprite. - 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
rectinstates.jsoncuts exactly its state out ofbutton-states.png, andstates.hover.opslists the values used. normal.pngis 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
| Symptom | Likely cause | How to check | Fix |
|---|---|---|---|
| The pressed image loses its bottom row | The 1 px offset moves the pixels inside the same canvas, so the last row falls off and row 0 becomes transparent | Compare the bottom edge of pressed.png with the source | Leave 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 doubled | focus.png is larger (44 × 20 for a 40 × 16 button) and Godot draws the focus texture over the base | states.json → states.focus.size | Centre it by −outline px, or use a ring-only overlay as described above |
| The disabled button almost vanishes on a dark background | Alpha 0.5 lets the background show through | Look at it over your real background, not on white | Raise the disabled alpha (0.7) or keep alpha 1 and only desaturate |
| Hover is barely visible on a light button | Brightness is additive and channels already near 255 clip | Pixel values above about 230 in the source | Use an overlay colour or contrast for hover instead of brightness |
Other ways to do it
| Option | When it is the better choice |
|---|---|
| Unity Transition = Color Tint | The 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 program | The 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.
| Target | Files | Check |
|---|---|---|
| Nine-slice | nine-slice.json · previews · SETUP.md | Measured 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 font | font.png · font.fnt (BMFont text) · font.json | Measured independent .fnt parserThe .fnt was read back by a parser written separately in the test: header and every glyph rect match. |
| Button states | states/*.png · strip · states.json | Measured 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 import | Godot NinePatchRect · Unity Sprite border · BMFont import | UNVERIFIEDNot 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).