Skip to content

Build a UI without UIListLayout

Nova does not implement UIListLayout. This tutorial builds a small, reusable layout module and a scrolling list from scratch. It replaces the common UIListLayout + UIPadding + UIAspectRatioConstraint combination.

What you need

  • ScreenGui
  • Frame (the list container)
  • ScrollingFrame (for scrolling)
  • TextButton children

No layout or modifier classes are required.

1. Create the screen

-- StarterPlayer/StarterPlayerScripts/Hud.client.lua
local Players = game:GetService("Players")

local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")

local screen = Instance.new("ScreenGui")
screen.Name = "Hud"
screen.ResetOnSpawn = false
screen.IgnoreGuiInset = true
screen.Parent = playerGui

local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.AnchorPoint = Vector2.new(0.5, 0.5)
panel.Position = UDim2.fromScale(0.5, 0.5)
panel.Size = UDim2.new(0, 320, 0, 420)
panel.BackgroundColor3 = Color3.fromRGB(28, 30, 36)
panel.BorderSizePixel = 0
panel.Parent = screen

local list = Instance.new("ScrollingFrame")
list.Name = "List"
list.Position = UDim2.new(0, 12, 0, 52)
list.Size = UDim2.new(1, -24, 1, -64)
list.BackgroundTransparency = 1
list.BorderSizePixel = 0
list.ScrollBarThickness = 6
list.CanvasSize = UDim2.new(0, 0, 0, 0)
list.Parent = panel

local title = Instance.new("TextLabel")
title.Name = "Title"
title.Position = UDim2.new(0, 12, 0, 12)
title.Size = UDim2.new(1, -24, 0, 32)
title.BackgroundTransparency = 1
title.Font = Enum.Font.SourceSansBold
title.TextSize = 22
title.TextColor3 = Color3.fromRGB(240, 240, 245)
title.TextXAlignment = Enum.TextXAlignment.Left
title.Text = "Inventory"
title.Parent = panel

The panel uses a fixed size here. In a real UI, use scale/offset combinations and recalculate on AbsoluteSize changes.

2. Add the layout module

Put this in ReplicatedStorage/Modules/Layout.lua:

local Layout = {}

local function isVisibleGui(child: Instance): boolean
    return child:IsA("GuiObject") and child.Visible
end

function Layout.column(container: GuiObject, padding: number, startY: number?)
    local y = startY or 0

    for _, child in ipairs(container:GetChildren()) do
        if isVisibleGui(child) then
            child.Position = UDim2.new(0, 0, 0, y)
            y += child.AbsoluteSize.Y + padding
        end
    end

    if container:IsA("ScrollingFrame") then
        container.CanvasSize = UDim2.new(0, 0, 0, y)
    end
end

function Layout.row(container: GuiObject, padding: number, startX: number?)
    local x = startX or 0

    for _, child in ipairs(container:GetChildren()) do
        if isVisibleGui(child) then
            child.Position = UDim2.new(0, x, 0, 0)
            x += child.AbsoluteSize.X + padding
        end
    end

    if container:IsA("ScrollingFrame") then
        container.CanvasSize = UDim2.new(0, x, 0, 0)
    end
end

function Layout.watch(container: GuiObject, axis: "x" | "y", padding: number)
    local function relayout()
        if axis == "y" then
            Layout.column(container, padding)
        else
            Layout.row(container, padding)
        end
    end

    container.ChildAdded:Connect(function(child)
        if child:IsA("GuiObject") then
            child:GetPropertyChangedSignal("AbsoluteSize"):Connect(relayout)
            child:GetPropertyChangedSignal("Visible"):Connect(relayout)
        end
        relayout()
    end)

    container.ChildRemoved:Connect(relayout)
    container:GetPropertyChangedSignal("AbsoluteSize"):Connect(relayout)

    for _, child in ipairs(container:GetChildren()) do
        if child:IsA("GuiObject") then
            child:GetPropertyChangedSignal("AbsoluteSize"):Connect(relayout)
            child:GetPropertyChangedSignal("Visible"):Connect(relayout)
        end
    end

    relayout()
    return relayout
end

return Layout

AbsoluteSize is only meaningful after the first layout pass. watch calls relayout once immediately and again whenever the container or a child changes size.

3. Populate the list

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Layout = require(ReplicatedStorage:WaitForChild("Modules"):WaitForChild("Layout"))

local ITEMS = {
    { Name = "Wood", Amount = 42 },
    { Name = "Stone", Amount = 17 },
    { Name = "Iron", Amount = 9 },
    { Name = "Gold", Amount = 3 },
    { Name = "Diamond", Amount = 1 },
}

for _, item in ipairs(ITEMS) do
    local row = Instance.new("TextButton")
    row.Name = item.Name
    row.Size = UDim2.new(1, 0, 0, 44)
    row.BackgroundColor3 = Color3.fromRGB(40, 43, 52)
    row.BorderSizePixel = 0
    row.AutoButtonColor = true
    row.Font = Enum.Font.SourceSans
    row.TextSize = 18
    row.TextColor3 = Color3.fromRGB(235, 235, 240)
    row.TextXAlignment = Enum.TextXAlignment.Left
    row.Text = string.format("  %s   x%d", item.Name, item.Amount)
    row.Parent = list

    row.MouseButton1Click:Connect(function()
        print("selected", item.Name)
    end)
end

Layout.watch(list, "y", 8)

Layout.watch positions each row and sets CanvasSize to the total height.

4. Make it resize correctly

If the panel size is not fixed, relayout when the container changes:

panel:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
    Layout.column(list, 8)
end)

For an aspect-ratio panel:

local function keepAspect(gui: GuiObject, aspect: number)
    local function update()
        local width = gui.AbsoluteSize.X
        if width > 0 then
            gui.Size = UDim2.new(0, width, 0, math.floor(width / aspect))
        end
    end

    gui:GetPropertyChangedSignal("AbsoluteSize"):Connect(update)
    update()
end

keepAspect(panel, 3 / 4)

5. Replacing the other modifiers

Missing class Replacement
UIPadding Add the padding to Position/Size (the example uses UDim2.new(0, 12, 0, 52)).
UIScale Multiply sizes in your layout function, or use scale-based UDim2.
UICorner Use a 9-slice ImageLabel behind the content.
UIStroke Add a slightly larger frame behind the content.
UIGradient Bake the gradient into an image.
UIAspectRatioConstraint Use keepAspect above.
UISizeConstraint Clamp the computed size in your layout function.
UITextSizeConstraint Clamp TextSize manually.

6. Common pitfalls

  • Everything is at (0,0). You did not call the layout function, or the children were added after the call. Use Layout.watch.
  • The list does not scroll. CanvasSize was not updated, or the ScrollingFrame has ScrollingEnabled = false.
  • Rows overlap. AbsoluteSize.Y is zero because the row has not been sized yet. Set an explicit row height or defer the first pass with task.defer.
  • The layout drifts. Re-running the layout is idempotent; make sure every child ends up at the computed position, not at its previous position.
  • The UI is blurry or clipped. Check ClipsDescendants and the parent's AbsoluteSize.

Next