Migrate from uGUI to Luna

This page is for projects whose UI lives in uGUI: Canvases, RectTransforms, TextMeshPro. If your project already uses UI Toolkit, skip straight to Adopt Luna in an Existing UITK Project.

Moving from uGUI to Luna is two migrations, and it pays to keep them separate in your head: first uGUI to UI Toolkit (a different UI system, not a different version of the same one), then plain UITK to Luna (a design system + workflow on top of UITK). The second half is the staged path on the UITK adoption page; this page gets you through the first half and tells you what not to port.

Both UI systems run side by side in one project without conflict, so you migrate screen by screen while the game stays shippable.

Before you port anything

Four project-level things decide how the whole migration feels. Settle them first; each one is cheap now and expensive to retrofit after ten screens.

Input System. UITK's runtime input needs the Input System package with a project-wide actions asset assigned. Check Project Settings ▸ Player ▸ Active Input Handling — if your project is on the old Input Manager, that switch is the real first task, and it touches gameplay code, not just UI. Luna ships a configured actions asset you can point at to get moving; swap in a project-specific one later. Some Luna views also expect a PlayerInput component alongside the actions asset, not just the asset. See Input Device Manager.

Fonts. A TMP_FontAsset is not a UITK font asset. UITK uses TextCore FontAsset, a different class, so you cannot reuse the ones TextMeshPro already built — regenerate from the source .ttf and wire the fallback chain into the theme (Theme Stack). Two settings to get right at creation time rather than later:

  • Turn off "Clear Dynamic Data On Build" on any dynamic font asset. Left on, every player build wipes the asset's glyph table and atlas and re-serializes it on disk — your editor then renders the UI with no text at all, and reverting the file does not fix it because the damage is a real rebake.
  • Leave multi-atlas textures enabled if your character set is bigger than ASCII. A single 1024×1024 atlas silently drops the overflow, and the glyphs you lose are the accented ones your localized strings need.

Your uGUI plugin inventory. List what your screens actually depend on before you port one, because each has a different landing place and a couple have none:

uGUI pluginLands on
Shadow / glow / outline plugins (TrueShadow, UIEffect, …)UI Effects, or UI Effect Baking when you want zero runtime cost
Particle-over-UI plugins (UIParticle, …)UI Render
Fly-to-target / collect animationsUI Attractor
Pooled/looping scroll listsui:ListView virtualization (ListView, List & ScrollView)
Tweening on RectTransformsUSS transitions + Transition Animation / Interaction Juice
Soft masks / rounded clippingoverflow: hidden + border-radius (Masking)
Gradient fillsNo USS equivalent — bake a texture (see What has no equivalent)

Panel Settings. Scaling strategy lives on the Panel Settings asset, not on each screen (Theme Stack). Decide now whether you author in device pixels or density-independent pixels, because it sets what every number in every USS file means. Note that Luna drives panel scale itself through the Responsive system — Panel Settings used by Luna views run in ConstantPixelSize and the responsive config is the authority.

The conceptual jump

Do UI Toolkit Basics first; it is short. Then use this table as your translation dictionary while porting:

uGUI habitUITK / Luna replacement
Canvas + CanvasScalerPanel Settings asset referenced by a PanelRenderer. Luna ships a configured one (LunaPanelSettings); scaling strategy lives there, not per screen
One GameObject per element, RectTransform anchorsA UXML document; elements are lightweight VisualElements laid out by flexbox, not GameObjects
Nested prefabs for reusable widgetsUXML templates + USS classes (taught in the next section); Luna's components replace the widget prefabs you built yourself
Image components + sprite slicing for panels, borders, glowUSS backgrounds and Luna's UI Effects (glow, outline, shadow, gradient, pattern) with no textures at all
TextMeshProUITK's built-in SDF text. Luna's theme wires the font fallback chain; Text Effects covers the animated-text ground TMP plugins used to
Button.onClick wired in the InspectorQuery the element once, subscribe in code; under Luna views this lives in OnUILoaded (First View)
Horizontal/VerticalLayoutGroupFlexbox: flex-direction + justify-content; group spacing becomes a margin on each child (Layout)
ContentSizeFitterNothing — flex sizes to content by default; you opt out with an explicit size
LayoutElement min/preferred/flexiblemin-* / max-* / flex-grow
ScrollRect (+ pooled list plugin)ui:ScrollView for short heterogeneous content, ui:ListView for long homogeneous lists (ListView)
Mask / RectMask2Doverflow: hidden, plus border-radius for rounded clipping (Masking)
Sibling index for draw orderDocument order — there is no z-index. Reordering the paint means moving the element
Screen.safeArea handling per screenluna-safe-area wrappers + the Responsive config
Animator / tweening plugins on RectTransformsUSS transitions plus Luna's Transition Animation presets and Interaction Juice
CanvasGroup alpha + SetActive for show/hideLuna view lifecycle: stack push/pop with automatic fades and occlusion (Navigation Graph)
Localization component per labelA runtime binding, so language changes re-resolve live (Localization)
World-space CanvasWorld-space UITK via LunaPanelSettingsWorldSpace (Theme Stack)
Particles / 3D models over UI via camera stacking or pluginsUI Render: camera to render texture to UI element, managed for you

Prefabs → UXML templates

The table row deserves its own mechanic, because it is the one uGUI habit you keep: a reusable widget is its own .uxml file, and other documents stamp instances of it. A gold resource pill that appears on five screens is one file:

xml
<!-- Components/GoldPill.uxml : the file's root element IS the component --> <ui:UXML xmlns:ui="UnityEngine.UIElements"> <ui:VisualElement class="gold-pill"> <ui:VisualElement class="gold-pill__icon" /> <ui:Label name="amount" class="gold-pill__amount" text="0" /> </ui:VisualElement> </ui:UXML>

Consumers declare the import once, then instance it like a prefab:

xml
<ui:UXML xmlns:ui="UnityEngine.UIElements"> <ui:Template name="GoldPill" src="../Components/GoldPill.uxml" /> <ui:VisualElement class="topbar"> <ui:Instance template="GoldPill" name="gold" /> <ui:Instance template="GoldPill" name="gems" class="topbar__pill" /> </ui:VisualElement> </ui:UXML>

What carries over from prefab thinking, and what does not:

  • Editing the template updates every instance, exactly like a prefab. UI Builder writes the <ui:Template> declaration for you when you drag one .uxml into another; src is a relative path.
  • Each instance is wrapped in a TemplateContainer, one extra element between the parent and your component's root. Placement styling (size, margins, flex) belongs on the instance (class on <ui:Instance> lands on that container); the component's own look stays inside the template file.
  • Element names repeat across instances, so scope your queries: root.Q("gold").Q<Label>("amount") finds the amount label of that pill specifically. Give each instance a unique name; keep inner names generic.
  • There are no property overrides. Variants are modifier classes (gold-pill--compact) or attributes your controller reads; live data (the amount) is written from C#, the same place your uGUI controller set TMP_Text.text.
  • From C#, a template is a VisualTreeAsset: serialize a reference and call Instantiate() to spawn instances at runtime (this is exactly what ListView.makeItem does for rows).

The Showcase shell is built this way: TopBanner and TabBar are templates instanced by the hub screen, and the Responsive safe-area example shows them in place.

Two ways to port a screen

There are two honest strategies, and picking the wrong one is the most expensive mistake on this page. They differ by roughly a factor of five in effort.

Redesign port — the default

Rebuild the screen's structure, then let the theme and components supply the visual layer you used to hand-place. Do this whenever you are allowed to change how the screen looks — new screens, unshipped screens, anything mid-redesign.

  1. List the screen's content, not its GameObjects: "header, resource bar, scrollable item grid, two action buttons".
  2. Author that structure in UXML using standard controls plus Luna components (GridView for the item grid, class="btn btn-solid" buttons, a ProgressBar for the resource bar).
  3. Style with USS classes, leaning on the theme tokens and utility classes. Resist recreating exact uGUI pixel positions; flexbox spacing is the point.
  4. Port the controller logic: your uGUI controller's serialized references become element queries, and its public API can stay identical, which keeps the rest of the game code untouched.

Screens that are mostly decoration (sliced sprites, baked glows, drop shadows) usually come out smaller in UITK because UI Effects generates that look from the shader.

Parity port — when the look is fixed

A live game with an art-driven identity does not get to redesign its Home screen as a side effect of a tech migration. If players would notice the difference, you owe the screen pixel parity, and then the rule above inverts: pixels are the spec.

The mistake here is eyeballing it. Screenshots lie about a few px, prefab inspectors lie about runtime values, and "close enough" compounds across twenty elements. Extract instead:

  1. Mirror the CanvasScaler in the Panel Settings — same reference resolution, same match. Then every extracted pixel value goes 1:1 into USS and you are comparing like with like.
  2. Dump the live hierarchy, not the prefabs, with a small editor script: resolved rect, sprite + its border, tint, text settings, effect parameters. Runtime layout and inspector overrides both differ from what the prefab file says.
  3. Port in small batches — two to four elements — and diff each batch against a reference capture programmatically. Pixel diffs find the 4px error that your eye approves.
  4. Still use flexbox. Extracted numbers become sizes, paddings and gaps; they do not all become left/top. See the warning below.

Budget accordingly: parity porting is several times slower than a redesign port, and the difference is almost entirely measurement, not authoring.

Extracted px are not a layout. Under a match-height scaler your reference width changes with aspect ratio, so a design captured at one width silently breaks at another — fixed-width grids wrap, centred rows hug the left edge. Convert absolute x offsets into flex intent as you go: centring is align-items: center or left: 50%; translate: -50% 0; right-alignment is a flex-grow: 1 spacer; a card grid is flex-wrap with percentage cell widths, and the design size becomes a max-width on the grid, not the cell. aspect-ratio (Unity 6000.5+) keeps a component's proportions while its width is driven by the parent.

What not to port

The page promised this, so here it is. Leave these behind:

  • Anchor math. Every anchorMin/anchorMax/pivot/sizeDelta combination has a shorter flexbox expression. Porting anchors literally produces a screen that is rigid in exactly the places flexbox would have been free.
  • Layout group / ContentSizeFitter scaffolding. Flex already does it. A VerticalLayoutGroup + ContentSizeFitter + LayoutElement sandwich usually collapses to two USS properties.
  • Your show/hide plumbing. SetActive chains, CanvasGroup alpha tweens and manual back-button stacks are replaced wholesale by the view lifecycle and Navigation Graph.
  • Your own localized-text helper. Bind through the localization API so a language change re-resolves live (Localization); a helper that resolves once at load is a bug you are porting forward.
  • Widget prefabs that Luna already ships. Check Views and Components before rebuilding a settings screen, confirmation popup, tab bar, progress bar or inventory grid.
  • Effect textures you no longer need. Baked glow/shadow sprites can often be deleted outright in favour of UI Effects — but see the note on baking below if you need the exact old look.
  • One prefab per variant. Three confirm-dialog prefabs are one UXML template plus typed arguments.

Traps that cost days

Generic UITK/Unity behaviour that surprises people arriving from uGUI. None of these are Luna-specific.

Text will not match TMP on the first try. Four separate causes, in the order they bite:

  • Outline and shadow are clipped by the font asset's padding ratio (atlasPadding / samplingPointSize). If your TMP asset was baked at a generous ratio and the UITK one at a tight one, large text loses its outline into detached blobs. Match the ratio.
  • TMP's _FaceDilate has no USS property. -unity-font-style: bold approximates it, since TextCore's fake bold dilates the SDF face.
  • A thick TMP "outline" is usually outline plus a directional underlay. Measure top/side/bottom separately before assuming it is uniform; the underlay becomes text-shadow. Keep the shadow's blur at least as large as any horizontal offset, or diagonal glyph edges show a notch.
  • Fake bold widens letter advance, so compensate with negative letter-spacing — and note UITK applies letter-spacing after the last character too, which drifts centred text; add matching left padding to re-centre.

Auto-sizing text. In Unity 6000.5 the property is -unity-text-auto-size: best-fit <min> <max>. Older property names are silently ignored — no import warning — so always confirm the text actually shrinks. Unlike TMP's auto-size, best-fit also accounts for line height and effects, so a box sized to the old TMP rect renders visibly smaller; give it roughly 20% more room. See Auto Size Text.

There is no text-transform. For text bound through Luna, pass casing: LunaTextCasing.Upper to BindLocalizedText: it re-cases with the active locale's culture (Turkish dotted/dotless i safe) on every locale change, and it works on Button too. For text outside the binding, uppercase in code with the culture-aware ToUpper, not ToUpperInvariant.

Selector specificity, twice. A bare Label type selector loses to the theme's .unity-label class rule, so a "global" text reset that targets the type does nothing. And a theme rule like .btn-solid > * beats a type selector too, which is why a label nested in a default-look button quietly takes the button's font size unless it carries its own class. Also worth knowing: default Label margin and padding are non-zero and asymmetric, which shifts every measured rect by a few px until you reset them.

Theme sheets do not resolve var() in their own rule declarations. Custom properties defined in a theme and consumed by a component sheet work fine, but a var() used inside a rule that reaches the panel through a .tss @import silently drops the whole declaration. Use literals at theme level. Relatedly: editing a .uss that a .tss imports does not re-flatten the theme — force-reimport the .tss or your play-mode session keeps serving the old rules.

A class that sets -unity-slice-* will mangle any sprite you draw on that element. In Luna the slice settings live on btn-solid (the default button look), so a sprite-skinned button should carry bare btn: it keeps the press/hover feel with no slices, no size clamp, and no font override. If a sprite renders as garbage and the console mentions borders "overridden by style slices", the element is carrying btn-solid (or another slicing class) underneath its art: drop the class or move the art to a child.

Absolute children ignore safe-area padding. Safe area is applied as padding on a wrapper, so absolutely-positioned children are unaffected. Screens built as "one absolute container with everything inside" do not respond to notches at all; make the safe-area wrapper a flex column and let its children be relative.

Scroll constants are in panel pixels. mouse-wheel-scroll-size defaults to 18, which is a crawl in a large reference space — rescale every px-based scroll constant for your panel. Physics knobs (elasticity, scroll-deceleration-rate, touch-scroll-type) are C# properties and UXML attributes, never USS. uGUI's ScrollRect and UITK's ScrollView use the same velocity decay formula, so an existing deceleration rate transfers 1:1. A ListView builds its own ScrollView, so configure that one in code. Finally, momentum and rubber-band are touch-only — the mouse wheel path has neither, so judge scroll feel in the Simulator or on device, never with a trackpad.

Virtualized lists must reset state, not just set it. In bindItem, use EnableInClassList for every state class on the row. Setting only the new state leaves a recycled row wearing the previous item's medal, badge or colour.

Q(name) can hit a hidden duplicate. Once several views coexist in one panel, a name can resolve to a 0×0 element in an inactive tree. When it matters, query all matches and take the one with a non-zero worldBound.

A standalone scene inherits nothing your boot flow configures. The classic symptom is a demo scene that feels smooth in the editor and sluggish on device, because nothing set Application.targetFrameRate and iOS defaulted to 30 fps — and the editor ignores targetFrameRate, which is exactly why it never shows there. Anything your boot sequence sets, a standalone UI scene needs its own copy of.

The Device Simulator silently resizes the UITK panel and changes Screen.safeArea. That is useful when testing insets and ruinous during a parity pass, where it makes your UITK captures a different scale from a uGUI reference taken in the same session. Close it before measuring. For the same reason, keep exactly one Game View open: the play loop and screen-capture calls can end up following different views.

Never put a margin on an element with aspect-ratio. Flex solves height first and the element comes out the wrong size. Put gutters on a wrapping cell instead.

What has no equivalent

Small, honest list — plan around these rather than discovering them mid-port:

  • Gradient fills. USS has no gradient background. Bake the gradient to a small texture (an 8px-wide strip stretched full-bleed is plenty for a vertical fade) and apply it as a background image.
  • Decaying oscillations. A USS transition is a single interpolation, so a tween plugin's punch/elastic scale cannot be expressed as one. Use Transition Animation or an experimental.animation helper.
  • z-index. Paint order is document order.
  • Per-character text animation beyond what Text Effects provides.

Migrating while shipping

Both systems coexist, so the practical shape of the migration is: theme first, then one screen at a time, newest screens first.

  • Order. Next new screen first, then modals (Confirmation and Input popups ship ready), then leaf screens, shell last. The shell is what everything else hangs off, so it is the one you want to port with the most experience, not the least.
  • Draw order between the systems is Canvas sortingOrder versus the Panel Settings sort order — set them in explicit bands rather than discovering the overlap during a demo.
  • Keep the controller API. If the ported controller exposes the same public surface, the rest of the game does not know which UI system is behind it, and you can flip a screen back if it regresses.
  • Effect baking for exact parity. UI Effect Baking reproduces an old shadow plugin's look closely, but the two use different falloff curves and can disagree on the sign of a vertical offset — verify against a reference capture rather than transcribing the old component's numbers.

Then follow the staged path

From here your migrated screens are ordinary UITK, and the rest is exactly the UITK adoption path:

  • Stage 0 + 1 (install, theme): do these before porting your first screen, so every screen you port lands themed from day one.
  • Stage 2 (components + features): this is step 2 of the porting recipe above; you are already doing it.
  • Stage 3 (views + navigation): port each screen directly into a view prefab rather than a bare PanelRenderer. Since you are rebuilding the screen anyway, going straight to the end state costs nothing extra, unlike the UITK-native case where screens already work.

And check Views before porting each screen; Settings, Save & Load, Main Menu, Pause, and Inventory already exist as extendable views. If something misbehaves in a way this page did not cover, Troubleshoot is the next stop.

Doing this with an AI agent

uGUI-to-UXML conversion is mechanical enough that AI agents handle it well when they can read the target patterns. This site publishes every docs page as raw markdown for exactly that; see AI Development for setup and migration prompts.

Two things make agent-driven porting work much better in practice: give the agent a way to extract ground truth itself (an editor script that dumps the live hierarchy beats screenshots), and have it write down each rule it discovers as it ports. The trap list above is what that habit produces after a few screens.

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL