Luna ships one responsive model, no fork. Drop in your UI, reference the theme, and it scales smoothly across desktop (1024 → 8K) and stacks on real phones — with zero per-screen config on the default path. Underneath, adaptivity is split into two independent axes, the same split modern web and native frameworks make:

  • Layoutwhich arrangement (columns, stacking, what hides). Handled by breakpoints (a USS class).
  • Scalinghow physically big everything is. Handled by automatic per-device scaling plus an optional player UI-Scale.

Keeping them separate is the whole trick: a breakpoint never resizes anything, and the scaler never changes the layout. A third axis, the safe area, keeps content clear of device chrome (notch, home indicator).

Desktop-first. Plain/base USS rules are the desktop layout. Mobile is the override: Luna applies a single .breakpoint-sm class on real mobile devices, and you write .breakpoint-sm { … } rules to reflow down. There is no width ladder and no width-flip — a narrow desktop window stays desktop and scales down; it never turns into the phone layout. (Preview the phone layout with the Device Simulator, not by dragging the window narrow.)

Layout — the breakpoint axis

Luna keeps exactly one breakpoint class on the root of every registered UIView (including ones that load later), and it's driven by device class, not window width:

  • Mobile (Application.isMobilePlatform is true) → the .breakpoint-sm class (the MobileBreakpointClass, default "sm"). This is the stacked/phone layout.
  • Desktopno class at all. The desktop width map ships empty, so ResolveForWidth returns "" at every width. Your plain/base rules render as-is and the scaler fits them to the window.
css
.my-card { width: 24%; } /* desktop = plain rules */ .breakpoint-sm .my-card { width: 100%; } /* phone override, down */

Real reflows from the flagship sample. Each pairs the plain desktop rule with its single .breakpoint-sm override — no width ladder in sight:

css
/* Tab bar — fixed-width pills on desktop become equal-flex items on phone (TabBar.uss) */ .tab-bar__item { flex-basis: auto; width: 168px; margin: 0 var(--space-xs); } .breakpoint-sm .tab-bar__item { flex-basis: 0; width: auto; margin: 0; } /* share the row evenly */ .tab-bar__icon { width: 96px; height: 96px; } .breakpoint-sm .tab-bar__icon { width: 52px; height: 52px; } /* shrink to fit 5 tabs */ /* Home tab — the CTA row is right-anchored on desktop, re-centered on phone (HomeTab.uss) */ .home-tab__play-row { position: absolute; right: var(--space-3xl); bottom: var(--space-xl); } .breakpoint-sm .home-tab__play-row { right: auto; left: 50%; translate: -50% 0; bottom: var(--space-3xl); } /* Hero detail — a fixed 440px side column stretches full-width on phone (HeroDetail.uss) */ .hero-detail__tabs-column { width: 440px; max-width: 440px; } .breakpoint-sm .hero-detail__tabs-column { width: 100%; max-width: 480px; }

Why device-class and not width? A narrow desktop window is still a mouse-and-keyboard desktop — flipping it to a touch layout at some pixel threshold produces a jarring discontinuity (the classic "950 px window suddenly jumps to the 2.4× phone layout"). Luna instead keeps the desktop layout and lets the scaler shrink it. Size is the scaler's job; the breakpoint only encodes the genuine desktop↔phone arrangement difference.

Live. The active class re-resolves automatically on resize, rotation, or a Device-Simulator swap, via a throttled (~5 Hz) screen check — you never drive it by hand. Screen metrics are read through UnityEngine.Device.Screen, so the editor Device Simulator feeds real per-device values.

WebGL escape hatch. Application.isMobilePlatform is false inside a mobile browser, so on universal web builds you override the decision with DeviceClassMode (Auto / ForceMobile / ForceDesktop) — set it on the asset, or at runtime from your own user-agent sniff:

csharp
LunaUIManager.Instance.SetDeviceClassMode(DeviceClassMode.ForceMobile);

Advanced — opt-in desktop width tiers. The desktop track is a width map; it just ships empty. If you genuinely need desktop reflow at specific widths, add named entries (name → minimum dp width) and author matching .breakpoint-<name> USS. Resolution is single-active (the largest threshold ≤ the current dp width wins), and widths are density-normalized (Screen.width × referenceDpi ÷ Screen.dpi, default reference 96). Note the shipped utilities and tokens (below) only cover the desktop base + mobile sm — desktop tiers are a bring-your-own-USS escape hatch, not a turnkey ladder.

Scaling — the size axis

A breakpoint chooses the arrangement; the scaler (LunaUIScaler) chooses the size and writes it to PanelSettings.scale. Unity hands you raw physical pixels (unlike the browser/OS, which give you a pre-scaled logical pixel), so Luna reconstructs the right per-device scale:

  • DesktopScale to window (the default). scale = clamp( min(width / 1920, height / 1080), 0.5, 2.0 ) against a 1920×1080 reference. It's a FIT: the constrained axis binds, so the reference never overflows. An ultrawide gets extra live canvas on the long axis (corner-anchored HUD spreads out) rather than letterbox bars. 0.5 is a legibility floor — below it the layout clips rather than reflowing, which defines your recommended minimum window size; 2.0 caps huge monitors (reached around 2× reference, i.e. 4K).
  • Mobile — automatic density scaling: scale = Screen.dpi ÷ MobileReferenceDpi — the single knob that tunes overall mobile size (raise it to shrink the UI, lower it to enlarge). The code default is 160 (raw Android dp); Luna's Essentials sample sets 256, tuned so its token canvas lands at a comfortable size from a small phone up to a tablet. Mobile is always density-scaled, regardless of the desktop mode.

Desktop mode is a single Advanced enum (DesktopUIScaleMode), not a choice you make per screen:

ModeBehavior
Scale to window (default)Window-FIT proportional scaling, as above.
ConstantFixed design size (); pair with breakpoint reflow if you want a fixed-size, reflowing desktop UI.
Match DPIDrives scale from Screen.dpi. Niche — desktop DPI is unreliable across monitors, so use only if you've validated it on your target hardware.

Device class is never decided by DPI. A Retina / 4K-laptop desktop (~220–280 dpi) stays desktop — Luna keys device class off Application.isMobilePlatform only, so a hi-DPI desktop is never mistaken for a phone.

Which panels get scaled? All on-screen ones, automatically. Luna auto-discovers the PanelSettings from your UIViews as they register and drives every one (dedup'd) — including separate per-layer panels. World-space / render-texture panels (targetTexture != null) are skipped, since those render into a fixed-size texture.

Player UI-Scale:

csharp
LunaUIManager.Instance.SetUserUIScale(1.25f); // clamped 0.5–2.0, persisted (PlayerPrefs "Luna.UIScale")

Wire your settings-screen slider to this; the final scale is baseScale × userScale, so it multiplies on top of the per-device base in every mode.

Why a per-device reference? A phone is held closer than a monitor, so its "logical pixel" is denser — there's no single scale right for both. Luna uses a tunable mobile reference (MobileReferenceDpi) and a desktop reference (96 / 1920×1080), the same thing devicePixelRatio / Android density / iOS @x encode under the hood.

Authoring — utilities & tokens

The Essentials sample theme ships the authoring layer you write responsive UI with. It's a starter kit you can adopt wholesale or replace — none of it is hard-wired into the runtime.

Tailwind-style utilities (layout-utilities.uss, spacing-utilities.uss, loaded globally via LunaUIDemoTheme.tss). The base class is the desktop default; the only breakpoint variant prefix is sm- (the phone override, active under .breakpoint-sm). The old md-/lg-/xl- width tiers were removed.

xml
<!-- row on desktop, column on phone --> <ui:VisualElement class="flex-row sm-flex-col" /> <!-- shown on desktop, hidden on phone --> <ui:VisualElement class="flex sm-hidden" />

Families: flex-row/col, flex-wrap/nowrap, justify-*, items-*, self-*, grow/shrink, hidden/flex, width/height fractions (w-full, w-1-2, w-1-3, w-2-3, w-1-4, w-3-4, h-full), and padding/margin on a 4px scale — p-*, px-*, py-*, m-*, mx-*, my-* (p-0 p-1 p-2 p-3 p-4 p-6 p-8 = 0/4/8/12/16/24/32 px), plus mx-auto. Each base class is the desktop default; the common steps also ship sm- phone variants. Two forced deviations from Tailwind because USS class names can't contain : or /: the prefix is sm-foo (not sm:foo) and fractions are w-1-2 (not w-1/2).

Real utility combos from the flagship sample — the base classes are the desktop layout; the sm- variants override on phone, so a whole screen reflows from the UXML alone with no custom USS:

xml
<!-- Profile: two 50% columns on desktop, stacked full-width on phone --> <ui:VisualElement class="profile-view__stats-achievements-row flex-row items-start sm-flex-col sm-items-stretch"> <ui:VisualElement class="profile-view__stats-block grow w-1-2 sm-grow-0 sm-w-full" /> <ui:VisualElement class="profile-view__achievements-block grow w-1-2 sm-grow-0 sm-w-full" /> </ui:VisualElement> <!-- Shop: a horizontal bundle row that becomes a vertical stack on phone --> <ui:VisualElement class="shop-tab__bundles-row flex-row items-start justify-center sm-flex-col sm-items-stretch sm-justify-start" /> <!-- Leaderboard toolbar: trim the horizontal padding on phone --> <ui:VisualElement class="leaderboard-tab__toolbar flex-row items-center justify-between flex-wrap py-3 px-8 sm-px-4" />

Global design tokens (Tokens.uss, also loaded via the theme). Spacing, radii, type scale, semantic text roles, the ink ladder, and component-size tokens (--control-h-*, --icon-*, --avatar-*, --badge-*, --bar-h-*, --field-w-*), plus fixed hairline widths (--line-*), all live at the panel :root, alongside --color-*. .breakpoint-sm overrides the text and component-size tokens down for the phone canvas (the --line-* hairlines stay fixed across breakpoints).

The override is just the same custom property restated under .breakpoint-sm, so every rule that reads it through var() updates at once — you tune one block, not every component:

css
/* Tokens.uss — desktop reference at :root, phone values under .breakpoint-sm */ :root { --text-body: 18px; --control-h-md: 44px; } .breakpoint-sm { --text-body: 14px; --control-h-md: 36px; } /* A component just reads the token; switching breakpoints re-flows it for free (GameTokens.uss) */ .gf-scroll-tight .unity-scroll-view__content-container { padding-left: var(--space-xl); } .breakpoint-sm .gf-scroll-tight .unity-scroll-view__content-container { padding-left: var(--space-lg); }

Why tokens are global, not per-screen. UITK voids the entire declaration if any var() inside it is unresolved. A globally-loaded utility (.p-3 { padding: var(--space-md) }) or component rule can land on any element — including modals and transition layers that get reparented out of a screen's subtree — so its tokens must resolve panel-wide. Defining tokens once at the panel root (not per-UXML) is what guarantees borders, radii, spacing, and fonts never silently drop. Size tokens use direct literals only (never --icon-md: var(--space-xl)) for the same reason — a chained, unresolved link would void the value.

Safe area — the device-chrome axis

A breakpoint picks the arrangement and the scaler picks the size; the safe area keeps content clear of device chrome — notch, status bar, rounded corners, home indicator. Luna reads Screen.safeArea and pushes the resulting insets as padding onto the elements you opt in.

Opt in by class. Tag a wrapper with luna-safe-area and Luna pads it to the device insets. Edge modifiers narrow which edges are padded:

ClassPads
luna-safe-areaall four edges
luna-safe-area--top / --bottom / --left / --rightonly that edge
luna-safe-area--horizontal / --verticalthat pair

Scale-correct + live. Insets are converted to panel units (divided by the active UI scale, so a dense phone doesn't get ~3× too much padding) and re-pushed on rotation/resize via the same throttled tick the scaler uses. Every registered UIView is covered, including per-layer panels; world-space / render-texture panels are untouched. Desktop and notch-less devices resolve to zero insets — the tag is simply inert there.

⚠️ The one rule: tag a dedicated wrapper

Luna writes inline padding on the tagged element, which overrides any USS padding on that same element. So put luna-safe-area on a wrapper whose only job is the inset, and keep your real padding on its children.

This isn't a workaround — it's the correct mobile look for free. The iOS large-title nav bar wants the bar background to bleed up under the status bar while the content sits below the notch. Untagged backgrounds stay full-bleed; only the inner wrapper insets:

xml
<ui:VisualElement class="top-banner luna-fx ..."> <!-- bg bleeds full-width, under the notch --> <ui:VisualElement class="luna-safe-area--top"> <!-- only the content insets --> <!-- logo, buttons, currency chips… --> </ui:VisualElement> </ui:VisualElement>

Don't stack safe-area classes for the same edge up a parent → child chain, or you'll double-inset. Flat ownership — each piece of chrome owns its edge once.

In the flagship sample. GameUiHub.uxml wraps the whole shell exactly this way: one full-bleed gradient background, then the top banner and the bottom tab bar each in their own single-edge wrapper, so the chrome clears the notch and the home indicator while the background bleeds under both:

xml
<ui:VisualElement name="ShellRoot" class="shell-root grow w-full"> <ui:VisualElement picking-mode="Ignore" class="shell-root__bg luna-fx luna-fx-matte" /> <!-- bleeds edge-to-edge --> <ui:VisualElement class="shell-chrome shrink-0 grow-0 luna-safe-area--top"> <!-- banner clears the notch --> <ui:Instance template="TopBanner" /> </ui:VisualElement> <ui:VisualElement name="ContentSlot" class="shell-content grow shrink" /> <ui:VisualElement class="shell-chrome shrink-0 grow-0 luna-safe-area--bottom"> <!-- tab bar clears the home indicator --> <ui:Instance template="TabBar" /> </ui:VisualElement> </ui:VisualElement>

Config lives on the Responsive Settings asset (the SafeAreaConfig section); it falls back to sensible defaults when no asset is assigned:

  • Disable — force insets off (default unchecked = safe area on). Tick only for immersive screens that intentionally draw under the notch.
  • Min Inset — a guaranteed baseline on every padded edge, in panel units, regardless of device.
  • Offset — per-edge ± adjustment (panel units): trim the device inset (-7) or add breathing room (+8).
  • Simulate (editor only) — stand-in insets to preview a notch in a plain Game view, no Device Simulator needed. Flows through Offset / Min Inset like a real device.

Read it from code:

csharp
SafeAreaInsets i = LunaUIManager.Instance.SafeArea; // panel units: i.Top / i.Bottom / i.Left / i.Right

Self-responsive layout — use fewer breakpoints

The modern move is to lean on intrinsic layout so most adaptivity needs no breakpoint at all:

  • GridView dynamic columnsSetDynamicItemPerLine(minItemWidth, maxColumns) + RegisterDynamicItemPerLineUpdate() fit as many columns as the width allows and re-pack on resize. This is the UITK analogue of CSS repeat(auto-fit, minmax(min, 1fr)), and it's effectively a container query — the grid reacts to its own width. See GridView.
  • Flexboxflex-wrap: wrap + % widths reflow continuously.

Both straight from the flagship sample — the column count and wrap points come from the available width, not a breakpoint:

csharp
// HeroesTab.cs — width-driven columns, recomputed on resize (the CSS auto-fit / minmax analogue) _grid.SetDynamicItemPerLine(_minCardWidth, _maxColumns); // as many columns as fit _grid.RegisterDynamicItemPerLineUpdate(); // re-pack on resize
css
/* Inventory.uss — percentage-width tiles wrap on their own: 10-up on desktop, 3-up on phone */ .inventory .inventory__section-pairs > * { width: 9.5%; margin: 0.5%; } .breakpoint-sm .inventory .inventory__section-pairs > * { width: 27%; margin: 3%; } /* HeroesTab.uss — flex slots fill the row with no breakpoint: min 150px, grow to fill, cap at 240px */ .heroes-tab__list .grid-line > * { flex-grow: 1; flex-shrink: 1; flex-basis: 150px; max-width: 240px; }

Reserve the sm breakpoint for the one or two genuine desktop↔phone layout flips; let flex / GridView handle the rest.

Setup

Scaling and safe area work out of the box with built-in defaults — nothing to wire. To customize them, and to enable breakpoints (the mobile .breakpoint-sm class and any desktop tiers live on the asset), create a Responsive Settings asset and assign it:

  1. Assets ▸ Create ▸ CupkekGames ▸ Luna UI ▸ Responsive Settings.
  2. Assign it to LunaUIManager ▸ Responsive Settings.

The Essentials sample ships a pre-configured asset and theme, so importing it gives you the whole stack already wired.

Debug tool

Tools ▸ CupkekGames ▸ Luna Responsive — a live, read-only window showing the runtime Screen size, DPI, reference DPI, effective dp width, applied scale, active breakpoint, and resolved safe-area insets, plus a built-in device simulator (presets for iPhone SE/14/15, Pixel 8, Galaxy Tab, iPad Pro, Steam Deck, Desktop 1080p/4K, …) and a Copy State button for sharing readings. It reads the runtime screen, so values are trustworthy in Play Mode and the Device Simulator. (It reports diagnostics only — it doesn't simulate per-breakpoint USS visuals.)

For builds, tick Log UI Scale On Boot on LunaUIManager (or call LogUIScaleState()): it logs one line with screen size, DPI, device-class path, final scale (base × user), active breakpoint, and safe-area insets — readable in Player.log / logcat / Xcode.

Samples

  • ResponsiveDemo (Showcase ▸ Components) — toggles device class (desktop ↔ mobile) and the scale axis live, showing the .breakpoint-sm override and scaling independently.
  • GridViewListViewResponsiveDemo / GridViewPaginationResponsiveDemo — width-driven dynamic columns (self-responsive). GridViewListViewResponsiveDemo also drives GridViewList's per-breakpoint config — scroller visibility / row height / scroll physics swapped on sm.
  • The LunaShowcase flagship UI (Showcase) is the full desktop-first model in practice.

API reference

csharp
// Layout — LunaResponsiveSettingsSO (the Responsive Settings asset, assigned on LunaUIManager) public string CurrentBreakpoint { get; } // active name, "" if none (desktop) public string MobileBreakpointClass { get; } // platform-gated mobile class, default "sm" public DeviceClassMode DeviceClass { get; } // Auto | ForceMobile | ForceDesktop public event Action<string> BreakpointChanged; public bool ResolveIsMobile(); // device-class decision public void SetDeviceClassModeValue(DeviceClassMode mode); public void SetBreakpoint(string name); // "" / null clears public string ResolveForWidth(int dp); // desktop width-tier resolution ("" if map empty) public int ComputeEffectiveWidth(); // live dp width public static int NormalizeWidth(int px, float dpi, float refDpi); // px → dp public IEnumerable<(string name, int minWidth)> GetBreakpoints(); // Device class + breakpoint application — LunaUIManager public LunaResponsiveSettingsSO ResponsiveSettings { get; } public void SetDeviceClassMode(DeviceClassMode mode); // runtime override (e.g. WebGL UA sniff) public void AddClassToAllUIViews(string className); public void RemoveClassFromAllUIViews(string className); // Scaling — LunaUIManager + LunaUIScaler public void SetUserUIScale(float scale); // player slider; clamped 0.5–2.0, persisted public float UserUIScale { get; } public LunaUIScaler.Result GetUIScaleResult(); // { MobileClass, ValidatedDpi, BaseScale, UserScale, FinalScale, DesktopMode } public void LogUIScaleState(); public enum DesktopUIScaleMode { ScaleWithWidth, Manual, AutoDpi } // labels: Scale to window (default) / Constant / Match DPI // Safe area — LunaUIManager (panel units; updated on resize / rotation / config change) public SafeAreaInsets SafeArea { get; } // .Top / .Bottom / .Left / .Right

After boot, the breakpoint, the scale, and the safe area all update automatically on screen changes — no per-frame cost beyond a lightweight throttled check.

See also

  • GridView — self-responsive dynamic columns
  • Source: Runtime/Scripts/Responsive/LunaResponsiveSettingsSO.cs, Runtime/Scripts/Responsive/LunaUIScaler.cs, Runtime/Scripts/Responsive/LunaSafeArea.cs, Runtime/Scripts/Managers/LunaUIManager.cs

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