Roblox UI Systems: Building Menus and HUDs That Scale Across Phone, Console, and PC

Have you ever watched a friend open your game on an iPhone and seen the shop panel you spent a week on slide halfway off the screen? If you built that panel on a 2560x1440 monitor and only ever tested it there, that is the expected outcome, not bad luck.
Most Roblox play does not happen on a desktop monitor. It happens on phones, tablets, and consoles, at aspect ratios and pixel densities you never opened Studio in, driven by input devices that have no cursor at all.
Your menu is the first thing those players touch, and usually the first thing that fails them. This guide covers the four decisions that decide whether a UI survives the trip off your monitor: sizing, safe areas, input model, and state.
Roblox UI breaks off-desktop for three reasons: it sizes in Offset pixels, it ignores device safe areas, and it assumes a mouse. Scale-based sizing, DeviceSafeInsets, and explicit gamepad focus fix all three.
Why Roblox UI Breaks The Moment It Leaves Your Monitor
Almost every broken Roblox interface fails for the same small set of reasons, and none of them are exotic. They are assumptions baked in during the first hour of building, when the only device in the room is the one under your hands.
The assumptions that reliably bite you include but are not limited to:
- Fixed pixel sizing. A frame sized at 400 by 250 pixels occupies a quarter of a 1440p monitor and nearly the entire width of a phone in portrait. The layout does not scale — it just gets relatively larger until it swallows the screen.
- The full viewport is yours. It is not. The Roblox top bar, the phone notch, the iOS home indicator, and console overscan all carve pieces out of the rectangle you think you own.
- Hover states carry meaning. On touch there is no hover, so any affordance you communicated through a mouse-over highlight is invisible to the majority of your players.
- Clicking is universal. A gamepad has no pointer. If you never wired focus navigation, a console player cannot reach your buttons at all — the UI is not ugly, it is unusable.
Each of these is cheap to fix on day one and expensive to retrofit on day ninety. That is the entire argument for building the sizing and input plumbing before you build a single screen.
Scale Versus Offset: The Sizing Decision That Decides Everything
Every position and size in Roblox UI is a UDim2, which carries four numbers: an X scale, an X offset, a Y scale, and a Y offset. Scale is a fraction of the parent's size, and offset is a raw count of pixels.
This is the whole game. A frame at UDim2.new(0.5, 0, 0.3, 0) is always half the parent's width and thirty percent of its height, on every device, forever.
Use Scale for anything that should occupy a share of the screen. Use Offset only for elements with a fixed pixel identity, such as a 4-pixel border or a 48-pixel icon.
| Dimension | Scale | Offset |
|---|---|---|
| What it means | Fraction of the parent container | Absolute pixels on the device |
| Under resolution change | Grows and shrinks proportionally | Stays the same physical pixel count |
| Correct for | Panels, columns, HUD zones, backdrops | Borders, strokes, icon squares, padding |
| Wrong for | Hairline dividers that must stay crisp | Anything that should fill a region |
| Failure mode | A 1-pixel divider becomes 3 pixels on a 4K display | A 400-pixel panel eats a phone screen whole |
The rule that removes most of the pain: do not mix scale and offset on the same axis of the same UDim2 unless a constraint pins the result. Mixed axes are where layouts drift, because the pixel component stays constant while the scale component stretches around it.
Scale alone has a second problem, though — a scale-sized square is only square on a square screen. That is what UIAspectRatioConstraint is for, and it belongs on every element whose shape carries meaning: avatar portraits, inventory slots, minimap frames, ability icons.
Two more constraints do the rest of the heavy lifting. UISizeConstraint clamps a scale-sized panel so it never grows past a sensible maximum on ultrawide or shrinks below legibility on a small phone, and UIScale multiplies an entire subtree, which is how you build a single global "UI scale" slider players can drive.
Layout containers finish the job. Prefer UIListLayout, UIGridLayout, and UIPadding over hand-positioning twelve siblings, because a layout recalculates when the viewport changes and hand-positioned children do not. If you are hand-authoring long sibling chains in Studio, that is a code smell worth solving in source instead — the same instinct that drives a clean Rojo git workflow for UI trees.
What Is A Safe Area, And Why Does It Eat Your HUD?
The viewport reported by workspace.CurrentCamera.ViewportSize is the full rectangle of the display. It is not the rectangle you are allowed to draw in.
Roblox reserves the top strip for its own core UI, and GuiService:GetGuiInset() tells you exactly how much — commonly around 36 pixels on desktop, and materially more on a notched phone. On top of that, the device itself claims space for camera cutouts, rounded corners, and the iOS home indicator, and televisions add overscan at the edges of a console frame.
Set ScreenGui.ScreenInsets to Enum.ScreenInsets.DeviceSafeInsets so Roblox insets your UI past notches, home indicators, and the top bar. Then anchor HUD elements inside that frame, not to the raw viewport.
The practical split is by layer. Full-bleed backgrounds — a blurred backdrop, a menu scrim, a vignette — want ScreenInsets set to None so they cover the entire display edge to edge, and older code achieved the same effect with IgnoreGuiInset.
Interactive content wants the opposite. Health bars, ability buttons, currency counters, and close buttons all belong inside a DeviceSafeInsets ScreenGui, because a close button under a notch is a close button that does not exist.
The corner tax. Corners are the most contested real estate on a phone: the notch takes top-center, the home indicator takes bottom-center, and rounded corners nibble all four. If your HUD pins critical controls to screen corners, budget an extra 5 to 8 percent of inset before you ship.
Because insets change when a player rotates a phone or resizes a window, treat the viewport as a live value rather than a startup constant. Listen to Camera:GetPropertyChangedSignal("ViewportSize") and recompute any layout you derived from pixel math, or better, avoid deriving layout from pixel math at all.
Designing For Three Input Models At Once
Developers reach for UserInputService.TouchEnabled, see it return true, and branch the entire UI on "this is a phone." That branch is wrong the moment a player plugs a controller into an iPad or taps the screen of a touchscreen laptop.
Branch on the most recent input, not the device. UserInputService.LastInputType tells you what the player used a second ago, and LastInputTypeChanged fires when it changes — which is your cue to swap button glyphs, show or hide the on-screen jump button, and enable focus navigation.
Branch UI on UserInputService.LastInputType, never on device capability flags. A player can swap between touch, gamepad, and keyboard inside one session, and the HUD should follow within a frame.
The three models want genuinely different affordances, and the differences are concrete. Touch has no hover and imprecise targets, gamepad has no pointer and needs a focus cursor, and keyboard-and-mouse has both a pointer and shortcut keys you should honor.
Here is what each one demands from the same screen:
- Touch. Every tappable element gets at least 44 pixels on its shortest side, matching Apple's Human Interface Guidelines and Android's 48dp floor. Spacing matters as much as size — two adjacent 44-pixel buttons with no gap still produce mis-taps.
- Gamepad. Nothing is reachable unless it is
Selectableand wired into a navigation graph. Every action also needs a face-button binding, especially B for back, bound throughContextActionService. - Keyboard and mouse. Hover states are real here and should carry information. Escape should close the top screen, Tab should cycle, and any shortcut you advertise in a tooltip must actually fire.
The trap is duplicating your screens per input model. Build one screen, then let the input layer decide which glyphs, hit areas, and navigation behaviors are active on top of it — a pattern that maps cleanly onto the input abstractions covered in the Gamepad API guide.
Gamepad Focus Navigation Without Losing The Cursor
Console UI lives or dies on one property: GuiService.SelectedObject. It is the currently focused element, and if it is nil when a menu opens, the player is stranded with a screen they can see and cannot touch.
Wiring it correctly is mechanical. Mark every interactive element Selectable, set GuiService.SelectedObject to a sensible default button the moment a screen opens, and clear it back to nil when the screen closes.
Set GuiService.SelectedObject to a starting button whenever a screen opens, and clear it on close. A menu that opens with nil selection is unreachable on a controller.
Roblox will guess the navigation graph for you, and the guess is fine for a tidy grid and wrong for everything else. Wire NextSelectionUp, NextSelectionDown, NextSelectionLeft, and NextSelectionRight explicitly on anything that is not a uniform grid — L-shaped layouts, sidebars beside content panes, and tab strips above lists all confuse the automatic solver.
Three more details separate UI that feels console-native from UI that merely functions:
- Trap focus inside modals. Set
SelectionGroupon a modal's root frame and use theSelectionBehaviorproperties to stop navigation from escaping into the HUD behind it. Focus leaking out of a dialog is the most common gamepad bug in Roblox UI. - Restore focus on reopen. Store the last selected element per screen and restore it when the player comes back, so returning to the inventory does not dump them at the top-left slot again.
- Style the focus cursor. The default selection box is a generic highlight;
SelectionImageObjectlets you replace it with something that reads at TV viewing distance, which is the only distance console players use.
Finally, respect the platform. Listen for GuiService.MenuOpened and release your focus and bindings while the Roblox menu is up, then take them back on MenuClosed — otherwise your bindings fight the platform's, and the platform wins.
The State Architecture That Keeps Menus From Becoming Spaghetti
The symptom is easy to recognize. Twelve different scripts each set .Visible on the same frames, and eventually two of them disagree — the shop is open and the pause menu is open and the HUD is half-hidden, and no single file explains why.
The fix is a boring one: hold UI state in one place and render from it. A single table describes which screen is on top, which tab is active, and what data is loaded, and every change is a mutation of that table followed by one render pass.
Keep one table of UI state and render from it. Never let two scripts set .Visible on the same frame — every screen change becomes a state mutation plus a single re-render.
Model navigation as a stack rather than a set of booleans. Opening the shop pushes a screen, opening a confirm dialog on top of it pushes another, and a single back action pops whatever is on top.
That stack is what lets Escape on PC, B on gamepad, and the Android back gesture all route into one function instead of three divergent ones. It is also what prevents the classic bug where closing a dialog closes the entire menu behind it.
You can build this by hand with plain OOP controllers, or lean on a declarative library — Roact, React-lua, and Fusion all exist for exactly this reason, and all of them enforce the render-from-state discipline you would otherwise have to hold in your head. Whichever you pick, keep the state modules pure and testable, which is the same separation that makes good Luau scripting patterns worth adopting in the first place.
One hard boundary: client UI state is presentation, never authority. The server owns balances, item counts, and entitlements, which is why the same discipline that drives anti-exploit design applies to your shop panel — the button is a request, not a transaction, and that distinction matters most on the screens where monetization lives.
Player-facing preferences — UI scale, HUD opacity, colorblind mode, chosen control scheme — are the exception worth persisting. Those belong in data stores, and if a player moves between phone and console mid-session, your cross-platform save state should carry them across.
Why Your Menu Costs More Frames Than Your Game
UI feels free because it is flat and static. It is not free — every GuiObject is an instance, every stroke and gradient is additional work, and a busy HUD can cost more per frame than the world behind it.
The single biggest offender is the unbounded ScrollingFrame. A 300-item inventory with 300 live item frames pays memory and draw-call cost for all 300 whether or not the player can see them.
Recycle scroll rows instead of instantiating them all. Keep a pool of roughly 20 visible frames, rebind their contents as the player scrolls, and set the canvas size from item count rather than from live instances.
Recycling is the fix, and it is the same technique used in every native list view: keep a small pool of frames, rebind their labels and icons on scroll, and drive CanvasSize from the item count rather than from real children. This becomes non-negotiable once your grid gets large, which is why it shows up early in any serious Roblox inventory system.
Four more habits keep the frame budget honest:
- Disable the ScreenGui, not the children. Setting
ScreenGui.Enabledto false takes the whole tree out of rendering in one operation, which is cheaper and less error-prone than toggling.Visibleon forty descendants. - Fade with CanvasGroup. Tweening
GroupTransparencyon a singleCanvasGroupreplaces tweening transparency on every child individually, which collapses dozens of tweens into one. - Stop updating labels every frame. A currency counter bound to
RenderSteppedrewrites text 60 times a second to display a number that changed once. Update on change, or throttle to a tenth of a second. - Budget your strokes and gradients.
UIStroke,UIGradient, and heavy transparency stacks all add fill cost, and they add it hardest on the low-end phones where you have the least headroom.
Measure on the weakest device you intend to support, not the strongest. A HUD that costs 2 milliseconds on your desktop can cost ten times that on a three-year-old Android phone, and that phone is a real customer.
The Test Loop That Catches This Before Players Do
None of the above holds unless you actually look at the UI somewhere other than your monitor. Studio's device emulator makes this a two-minute check rather than a shipping surprise.
Here is the pass to run before any UI change goes live:
- Portrait phone, smallest supported. Confirm nothing overflows, nothing hides behind the notch, and every button clears the 44-pixel floor.
- Tablet, landscape. Confirm your panels are clamped by
UISizeConstraintand have not stretched into unreadable letterboxes. - Ultrawide, 21:9. Confirm scale-sized elements have not sprawled and that anchored HUD corners are still reachable without a neck turn.
- Gamepad only, mouse unplugged. Open every screen, navigate it end to end, and confirm B backs out of each one without stranding focus.
- Mid-session input swap. Start with touch, switch to gamepad, switch to keyboard, and confirm glyphs and controls follow within a frame.
Run that pass on every screen before you consider the screen done. It takes minutes, and it catches the entire class of bugs that otherwise reaches players first.
Frequently Asked Questions
Can I just use Scale for everything and skip Offset?
Almost — but pure Scale distorts anything whose shape carries meaning, because a scale-sized square is only square on a square screen. Pair Scale with UIAspectRatioConstraint on icons and slots, and keep Offset for borders and padding.
What is the difference between IgnoreGuiInset and ScreenInsets?
IgnoreGuiInset is the older boolean that only concerns the Roblox top bar. ScreenInsets is the modern enum that also accounts for device-level intrusions like notches and home indicators, which is why DeviceSafeInsets is the right default for interactive layers.
Do I need a separate UI tree for console?
No, and building one is a maintenance trap. Build one screen and layer input-specific behavior on top of it — focus navigation, glyph swaps, and enlarged hit areas — driven by LastInputType.
Should the client cache inventory or shop data in UI state?
Cache it for rendering, never for authority. The server validates every purchase and every item move, and the client's copy exists purely so the panel can draw without a round trip.
How many instances is too many for a HUD?
There is no universal number, but there is a universal method: profile on your lowest supported device, not your development machine. If your HUD costs more than a couple of milliseconds a frame there, start recycling and collapsing tween targets.
Where To Take This Next
UI is the layer where every other system becomes visible to the player, so it inherits the correctness of what sits beneath it. A shop panel is only as trustworthy as its server validation, and an inventory grid is only as fast as the data model behind it.
If you are building out those layers, the companion guides on inventory systems, data stores, and anti-exploit design cover the systems your menus will be rendering. Build the sizing, safe-area, and focus plumbing first, and every screen you add afterward inherits it for free.


