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:
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-smclass 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.)
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:
Application.isMobilePlatform is true) → the .breakpoint-sm class (the MobileBreakpointClass, default "sm"). This is the stacked/phone layout.ResolveForWidth returns "" at every width. Your plain/base rules render as-is and the scaler fits them to the window..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:
/* 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:
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.
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:
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).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:
| Mode | Behavior |
|---|---|
| Scale to window (default) | Window-FIT proportional scaling, as above. |
| Constant | Fixed design size (1×); pair with breakpoint reflow if you want a fixed-size, reflowing desktop UI. |
| Match DPI | Drives 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.isMobilePlatformonly, 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:
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 thingdevicePixelRatio/ Android density / iOS@xencode under the hood.
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.
<!-- 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:
<!-- 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:
/* 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.
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:
| Class | Pads |
|---|---|
luna-safe-area | all four edges |
luna-safe-area--top / --bottom / --left / --right | only that edge |
luna-safe-area--horizontal / --vertical | that 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-areaon 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:
<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:
-7) or add breathing room (+8).Read it from code:
SafeAreaInsets i = LunaUIManager.Instance.SafeArea; // panel units: i.Top / i.Bottom / i.Left / i.RightThe modern move is to lean on intrinsic layout so most adaptivity needs no breakpoint at all:
GridView dynamic columns — SetDynamicItemPerLine(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.flex-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:
// 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/* 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.
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:
Assets ▸ Create ▸ CupkekGames ▸ Luna UI ▸ Responsive Settings.LunaUIManager ▸ Responsive Settings.The Essentials sample ships a pre-configured asset and theme, so importing it gives you the whole stack already wired.
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.
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.// 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 / .RightAfter boot, the breakpoint, the scale, and the safe area all update automatically on screen changes — no per-frame cost beyond a lightweight throttled check.
Runtime/Scripts/Responsive/LunaResponsiveSettingsSO.cs, Runtime/Scripts/Responsive/LunaUIScaler.cs, Runtime/Scripts/Responsive/LunaSafeArea.cs, Runtime/Scripts/Managers/LunaUIManager.csSettings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction