Folio Docs
Fogspire/Folio
git clone https://github.com/Fogspire/Folio.git Plugins/Folio

Building UI with Folio

You have been asked to build UI for a project that has this plugin. Read this page before writing anything: it is the difference between working UI and UI that renders nothing while reporting no errors.

The one thing that will catch you

RmlUi is not a browser and RCSS is not CSS. Valid-looking CSS parses cleanly and then does nothing, with no warning:

grid-template-areas · background-image · text-shadow · outline · clamp() / min() / max() · ::before / ::after / content · aspect-ratio · position: sticky · object-fit · border-style · :has()

display: grid, calc() and var() with --custom-properties do work, with limits listed in docs/layout.md.

And these are wrong in ways that look right:

WrittenWhat happens
transition: opacity 120msNo animation. Times are seconds; ms does not exist. Write 0.12s.
transition: opacity 0.12s ease-outNo animation. Easings are RmlUi's. Write cubic-out.
rgba(0, 0, 0, 0.5)Fully transparent. Alpha is 0-255. Write #0000007f.
line-height: normalNo such value. Give a number.
font-family: Inter, sans-serifOne family only. No stacks.
.menu { nav: vertical; }Does nothing. nav is read from the focused element and does not inherit.
flex: 1 1 0 in a columnCollapses the item. Use flex: 1 1 auto vertically.

UI_CONVENTIONS.md is authoritative for capability. When something does nothing, look there before debugging; it is usually on the banned list.

Do this, in this order

1. Check what the project already has

Shell
ls UI/                      # screens, lib, art, fonts, mock
cat UI/theme.rcss           # the game's palette, if it has one
ls Plugins/Folio/rcss/recipes/

If the project has its own conventions document, it governs taste and this one governs what exists.

2. Start from a recipe, not from nothing

Plugins/Folio/rcss/recipes/ has complete working screens (main_menu.rml, pause.rml, confirm.rml, nameplate.rml and more). Copy one into UI/screens/ and edit it. They pass --strict and --a11y with no findings.

2a. Decide how flat it should be

Flat is a choice, not the default. The recipes are flat so they stay neutral; a modern game UI is not. Unless the brief or the game's existing UI calls for flat (pixel art, a retro or terminal look, a screen meant to read as print), give it depth: a menu turned a few degrees on rotateY, panels angled toward the camera, a focused item that comes forward. If neither the prompt nor the project makes the style clear, ask the user before building.

RCSS
.menu                    { perspective: 1200dp; perspective-origin: 0% 50%; }
.menu-item               { transform: rotateY(14deg); transition: transform 0.18s cubic-out; }
.menu-item:focus-visible { transform: rotateY(6deg) translateZ(24dp); }

Keep angles shallow, roughly 5 to 20 degrees, so it reads as a plane in space without foreshortening the text. Put perspective on the parent so siblings share one vanishing point. A tilted control needs its :active restated with the rotation (see 6a). Costs are in UI_CONVENTIONS.md §4.

A template's head merges into any document that links it, so one line brings in the whole component library:

RML
<link type="text/template" href="/folio/lib/lib.rml"/>

Compose its classes rather than styling bare divs: the components already have the box-sizing, flex bases, scrollbar geometry, tab-index and nav right, and every one of those is a documented trap.

4. Say what size the document was designed for

One line in the head, so whoever assigns the document in the editor gets the size filled in rather than guessing it:

RML
<meta name="folio-size" content="1024x640"/>

Nothing measures this: body is almost always 100%, so a laid-out document only reports the size it was given. Write it for any document with a size it assumes, which is every world document, terminal, panel and nameplate. A full-screen HUD that fills whatever it is given leaves it out.

5. Put colour in the right place

/folio/lib/*.rcssstructure only, no colour anywhere
/folio/themes/default.rcssthe tokens and the skin
the game's own stylesheetanything it disagrees with

The library uses no var(), so a theme is an override block and the later rule wins. Never put a colour in a component rule: it can then only be overridden by restating the rule in full.

Use the token classes (.c-accent, .t-title, .s-pad-m, .focusable) and retune what they mean, rather than inventing parallel ones.

6. Make it navigable

Every focusable element carries its own tab-index: auto and its own nav. A container's nav is never consulted.

RCSS
.menu-item { tab-index: auto; nav: vertical; }
body       { nav: auto; }            /* or the first direction press goes nowhere */

<button> is not a button: only body, img, handle, the form controls, progress and tabset are real element types. Everything else is a generic element with no behaviour until you give it tab-index: auto. With that, Enter, Space and the gamepad confirm all synthesise a click.

Style :focus-visible, never :focus.

6a. Make a press look pressed, and a held press repeat

A press shows nothing on a control that leans. The library gives you .focusable:active { transform: scale(0.98); }. transform is one property rather than a list, so on a control with a transform of its own that rule replaces the skew instead of adding to it, and any :focus-visible rule you wrote is the same specificity and later in the cascade, so on a focused control the press never wins at all. The symptom is "click animations do not work with the mouse".

Restate :active for every state the control can be pressed in, transform and all:

RCSS
.step                        { transform: skewX(-11deg); }
.step:focus-visible          { transform: skewX(-11deg) scale(1.1); }
.step:active,
.step:focus-visible:active   { transform: skewX(-11deg) scale(0.94); }   /* both, or focused never presses */

Holding a control does not repeat. Neither RmlUi nor Folio has press-and-hold: click fires once on release. The repeat is the game's, driven from mousedown:

RML
<span class="step focusable"
	data-event-click="Adjust(it_index, 1)"
	data-event-mousedown="HoldStart(it_index, 1)"
	data-event-mouseup="HoldStop"
	data-event-mouseout="HoldStop">&gt;</span>

Rules for the C++ side; the third is the one everybody misses:

  • mouseout as well as mouseup. A cursor dragged off the button never releases on it, and without this the repeat runs forever.
  • HoldStart performs the first step itself, then waits half a second before the first repeat, so a slow deliberate click is still one step. Later repeats are much faster (0.07 s, then 0.04 s, then 0.02 s); only the first repeat has to clear a human click. Too short here and single clicks intermittently move the value twice, which reads as the swallow below being broken when it is not.
  • mouseup raises click too. HoldStart has already acted on the press, so the click that follows adds a second step and every mouse press moves the value twice. Set a bSwallowClick flag in HoldStop and throw the next click away. A pad or a keyboard sends click with no mousedown in front of it and passes straight through.
  • Check the flag in whatever command the markup's data-event-click names, not in one command you picked. A stepper binds Adjust and a button binds Activate, and a mouse press is followed by a click on both; guarding only one leaves the other double-stepping. Keep the raw "move the value" routine separate and have the hold and the drag call that, so the repeat cannot swallow itself.
  • Tick the repeat on FTSTicker, not a world timer, if the screen can be up over a paused game.

A slider drags with drag, and the delta is all you get. drag: drag; makes the bar pickable and moves nothing. A data expression cannot ask an element where it is or how wide it is, so absolute positions are out; ev.mouse_delta_x is relative and is the whole interface:

RML
<span class="bar" data-event-drag="DragRow(it_index, ev.mouse_delta_x)">…</span>

Accumulate the pixels in C++ and carry the remainder: a drag reports a few pixels at a time, and rounding each event to zero steps means the bar never moves.

Typing a number into a row means an entry field outside the list. A data-value two-way binding cannot write into a data-for row: array elements are mirrored, so the write lands on the copy and the setting never hears it. Put EditText on the model itself as a BlueprintReadWrite FString, open a small <input type="text"> over the screen, and commit from C++ against the row the entry was opened on. FocusElement puts the caret in it, since autofocus only runs when the document loads.

7. Label anything a player reads

RML
<p data-loc="start">Start Game</p>
<button class="btn focusable" id="icon" folio-label="Settings"><img sprite="icon_gear"/></button>

data-loc on every user-visible string, data-noloc on the ones that are not language, and folio-label on any control whose visible content is not its name. Both are cheap now and expensive to retrofit across a finished game.

folio-label is not only for the checker: while a screen reader is running it is what the player hears. uipreview --a11y-tree prints the whole document as it is read out.

Size type in rem, not dp. The accessibility text scale writes the document's root font size, so rem follows it and dp does not; --text-scale 1.5 --a11y names the text it could not move and anything that stopped fitting.

8. Verify before you say it is done

Shell
./Plugins/Folio/bin/win64/uipreview.exe UI/screens/yours.rml --root . --strict --a11y

Non-zero exit means it is not right. Nearly every RCSS mistake is a warning rather than an error, so a check that only looks at errors passes a broken stylesheet.

Then look at it:

Shell
./Plugins/Folio/bin/win64/uipreview.exe UI/screens/yours.rml --root . --screenshot out.png

Open the screenshot. It is the only way to catch a layout that is valid and wrong.

9. Know what an effect costs before you reach for one

Four properties are drawn by rendering the element into an offscreen target and reading it back: box-shadow, filter, backdrop-filter, mask-image. Everything below follows from that, and none of it is visible in a screenshot.

A layer costs a pass, not pixels. Two 236x50dp drop shadows measured heavier than the full-screen vignette beside them. Count them; the size barely matters.

Never put one on an element whose box changes. This one is a bug rather than a trade-off. The texture is cached against the element's boxes, so a transition: width misses that cache and rebuilds the texture every frame of the transition: a pass, a readback and a new texture. Transition transform: scaleX() on a fixed box instead. Blur radius makes no difference; it is the rebuild, not the blur. --strict fails on this.

An idle document is free, and one animation ends that. Folio skips the frame entirely when nothing changed, so layers on a settled screen cost nothing. A running animation means the document is drawn every frame, but only layers whose content changed are drawn again: the rest are reused from the cache (folio.Layers.CacheStats). An animation inside a layer's element, or one changing the element's box, still costs that layer every frame.

Baking moves the cost, it does not remove it. folio.Bake turns an effect into a texture, and a texture is memory plus a decode and an upload on the game thread the first frame it draws. A full-screen one is 8 MB of that, landing inside Render at the moment something dramatic happens. Bake soft things small: render at full size and write a quarter:

folio.Bake UI/screens/hud_baker.rml #vignette UI/art/vignette.png 1920 1080 4

Measure before you change anything. stat Folio in the editor. RT: replay layer is the render thread, Render is the game thread, and confusing the two sends you at the wrong half of the frame.

See docs/performance_profiling.md.

Quick reference

Layout is flexbox, or display: grid for a real grid. calc() mixes a percentage with lengths. No aspect-ratio. dp for anything that should scale with the player's UI scale; px only for hairlines.

Colour is hex with hex alpha: #1f1f28, #0000007f.

Animate transform, opacity, color, background-color, border-color, filter. Never width, height, margin, padding, top/left, font-size - each triggers layout every frame.

Images are sprites from a @spritesheet, drawn with <img sprite="..."> or decorator: image(...). Tint single-colour art with image-color rather than shipping four variants. A texture the game registers is named folio://<name> and works anywhere a source does: that is how a scene capture, a paper doll or a rendered item reaches a document.

Text is distance-field and scales freely. Font effects reach about 0.104em, so an outline on small text gets clamped. A wider glow or shadow is filter: glow(#ff8800 24dp 2) or filter: drop-shadow(...) on the element, which costs a layer. A game's own GPU effect is filter: shader("name args"); see docs/shader_filters.md.

Prose with emphasis in it goes in a <text>, whose contents are Folio's markup rather than RML: You <angry>must</> go to the {tag:Place.Mountain.Temple}. That is one translatable string with one data-loc. reveal: typewriter types it out. See docs/rich_text.md.

Fonts are not limited to the bundled Inter and Roboto Mono. Pick the face the design wants, fetch the .ttf from fonts.google.com and drop it in UI/fonts/ with its licence. Do not settle for a bundled face and call it a constraint.

A slanted or curved box is art, not a box. A background's edge is one pixel of antialiasing, so a skewed panel or a shallow diagonal steps visibly. A line, arc, outline or flat-coloured shape is a <path>, antialiased at any angle and bindable to data (see docs/path.md); a gauge's ticks and numbers are a <scale> (docs/scale.md). For shaded art, draw it as an SVG and bake it to a vector sprite, whose edge is exact at any angle and size; or build it as a box, bake it to a PNG with the edges smoothed (Folio Studio's Bake to PNG with 2 samples, or uipreview --dp-ratio 2 --bake drawn at half size) and draw that. Supersampling (below) is for what is left.

Slanted and curved edges that stay boxes want Supersample = 2 on the widget. Supersampling draws the document into a target twice the size and scales it down; the layout, the shaping and the hit testing are untouched, because only the target is denser. Text skips the target and is drawn to the screen at its own resolution, so it is the same text either way, except inside a filter, a shadow or a mask, which read their layer back.

Two, and only two. It costs a render target of the viewport times the count, squared, for the document and for every layer it pushes: 33 MB each at 1080p and 2x, 531 MB each at 4K and 4x, which is an operating system memory warning and a collapsed frame rate. Four buys an edge nobody sees. See docs/supersampling.md for the measurements.

Put that choice behind a console command when you wire Folio into a project: take a value of 1 to 4, hold it on whatever owns the widget, and rebuild the document when it changes. Whether 1 or 2 is right cannot be judged from the source; a command makes it an A/B in the game. Untouched it costs nothing at runtime, and changing it costs one document rebuild.

Never animate opacity on an element with overlapping children. It is inherited in RCSS, unlike CSS, and it is multiplied into premultiplied vertex colours rather than compositing the subtree as one image. Each descendant is drawn separately and overlapping quads accumulate, so a light panel over a dark shadow blows out to white halfway through the fade and is correct at both ends. Animate transform for an entry, or put the opacity on a single leaf element. Fading a group as one image needs a filter, which costs a render target.

The same property carrying both a transition and an animation loses one of them, silently apart from a warning that names neither rule. One or the other, never both.

If the project uses CommonUI, turn off its virtual pointer. All three default on, and live in the game's DefaultEngine.ini:

INI
[ConsoleVariables]
CommonUI.ShouldVirtualAcceptSimulateMouseButton=0
CommonUI.EnableVirtualPointer=0
CommonUI.SyncCursorToFocusedWidgetOnInputMethodChange=0

The first stops the pad's accept button becoming a left mouse click at CommonUI's software cursor, which presses whatever the cursor is over rather than what is focused. The second stops the stick driving that cursor, which otherwise makes a player holding only a pad generate real mouse movement. The third stops the cursor jumping to the focused widget when the player picks up the pad: the focused widget is the canvas, so the jump is to the middle of the screen and Folio reads the move it synthesises as the mouse being taken back. All present as focus bugs and none is one. With the first off, a CommonButton ignores the pad's confirm unless it has a triggering input action, so also set CommonButtonAcceptKeyHandling=TriggerClick under [/Script/CommonUI.CommonUISettings] in DefaultGame.ini. Set CommonUI Settings under the toolbar's Folio button writes all four. See docs/input_and_navigation.md.

A HUD declares <meta name="folio-interactive" content="false"/> in its head (or sets Interactive false on its UFolioWidget). Anything the player does not click or navigate must not be focusable: Slate restores focus to a focusable widget on window reactivation, so an interactive HUD steals input on alt-tab and the player cannot move. Pair it with SetAllUserFocusToGameViewport() on OnApplicationActivationStateChanged, because anything else the game put on the viewport can take focus the same way. See docs/hosting.md.

Sizing already matches UMG. Leave it alone. Scale Follow defaults to 1, which is GetDPIScaleBasedOnSize for the viewport, so a document sizes exactly as a UMG widget does. 0 is a constant pixel size and in between grows less than proportionally; use those only when a design asks.

If a HUD looks correct at 1080p and grows or shrinks twice as fast as it should elsewhere, it is a squared ratio, not a stylesheet problem. A context unit is a pixel; RmlUi converts dp lengths itself, so scaling drawn geometry by the ratio again squares it. Folio.Scale.MatchesUMG guards that. Diagnose with folio.Screens (drawn must equal px) and with kb.ScaleProbe, which puts a real UMG box on screen to measure against.

Drag and drop: drag: drag-drop to pick up (not drag: drag, which never finds a drop target), folio-drag="move" to move it, folio-drop="remove|hide|accept" on the zone. Style .folio-dragging and .folio-dragover.

Moving and resizing a window needs no code: <handle move_target="id"> and <handle size_target="id">, with the target position: absolute.

Data comes from a bound object: {{ Health }} in text, data-class-*, data-if, data-for, data-attr-*. No JavaScript.

Where things go

UI/
  screens/     documents the game opens
  lib/         the game's own components, if it has any
  art/         packed atlases and the stylesheets that declare them
  fonts/       faces the game registers
  mock/        <model>.json, so a document previews with data
  terms/       *.terms, the names the whole game shares - places, characters, items
  theme.rcss   the game's palette

Full documentation

docs/README.md is the index. The pages you are most likely to need:

UI_CONVENTIONS.mdauthoritative. What exists, what is banned
docs/layout.mdflexbox and the traps
docs/components_and_themes.mdthe library and the colour split
docs/input_and_navigation.mdthe nav rule and everything from it
docs/data_binding.mddriving a document from gameplay
docs/testing_a_document.mdevery check and what it catches
docs/accessibility.mdthe annotations and the build gate; screen reader and presentation settings are their own pages
docs/rich_text.md<text>, terms and the typewriter
docs/known_gaps.mdevery known limit, when something does not work and you need to know whether it can
docs/form_controls.mdthe real element types, and why <button> is not a button
docs/modals.mdtrapping focus in a dialog, which is wrong by default
docs/console.mdevery folio.* command

What not to do

  • Do not write custom focus logic. Navigation is RmlUi's spatial navigation. Set nav and tab-index and stop.
  • Do not put colours in folio/lib/. It forks the library.
  • Do not report a screen as finished without running --strict. A clean-looking render proves almost nothing here.
  • Do not invent properties. If it is not in UI_CONVENTIONS.md §1, it does not exist and does not warn.
  • Do not animate layout properties. It is the one way to make Folio slower than the UMG it replaced.
  • Do not trust an editor run for bindings. Outside the editor an FName keeps whichever spelling was interned first, so a UPROPERTY or UFUNCTION named Position can come back as position in a packaged build. Struct members inside arrays (Rows[i].Position) match case-insensitively for this reason. Top-level view-model properties and commands are still matched as spelled, so a packaged build logging Could not get value from data variable or an unknown event names one that lost its case: rename it to something no other code interns in another case. Test bindings in a packaged build before calling them done.