Use when building a tutorial, onboarding step or "click here" hint that dims the whole screen except one UI element (a spotlight or coach mark) in a Roblox game. Covers the UIStroke cut-out trick, tracking the target's AbsolutePosition and AbsoluteSize every frame, GUI insets, UICorner, labels, and blocking clicks outside the target.
Use the robloxvibecoders MCP: call get_resource with id "skill-roblox-tutorial-highlight" and install it as a Claude Code skill in this project (.claude/skills/roblox-tutorial-highlight/SKILL.md).
Skills in ~/.claude/skills work in every project. Put them in your project's .claude/skills folder to share them with your team.
What your AI reads
SKILL.md
---
name: roblox-tutorial-highlight
description: Use when building a tutorial, onboarding step or "click here" hint that dims the whole screen except one UI element (a spotlight or coach mark) in a Roblox game. Covers the UIStroke cut-out trick, tracking the target's AbsolutePosition and AbsoluteSize every frame, GUI insets, UICorner, labels, and blocking clicks outside the target.
---
# Tutorial spotlight highlights
A spotlight darkens everything except the button the player should press next. Roblox UI has no masks, but a
`UIStroke` gives you one for free:
1. Put a **fully transparent Frame** exactly over the target. Its inside stays clear, so the real button shows.
2. Give it a **UIStroke** with a huge `Thickness` (10000), black, partly transparent, positioned **outside** the
border. The stroke fills the screen around the frame and leaves a hole where the target is.
3. Every frame, copy the target's `AbsolutePosition` and `AbsoluteSize` onto the frame, so the hole follows layout
changes, tweens, scrolling and screen rotation.
## Coordinates and insets
`AbsolutePosition` of anything inside any ScreenGui uses one shared coordinate system (its origin is the bottom-left
corner of the Roblox top bar), no matter how each ScreenGui sets `IgnoreGuiInset` or `ScreenInsets`. So never add or
subtract a hard-coded topbar height. Instead, put a full-screen `Root` frame in the overlay and subtract
`Root.AbsolutePosition`: that converts the target's position into offsets inside the overlay, whatever its insets.
The overlay itself must cover the whole screen, topbar and notch included:
- `ScreenInsets = Enum.ScreenInsets.None` (the old way is `IgnoreGuiInset = true`, which only clears the topbar).
- `SafeAreaCompatibility = Enum.SafeAreaCompatibility.None`, so Roblox doesn't treat the giant stroke as fullscreen
UI and stretch it.
- A high `DisplayOrder` so it draws above the game UI, and no ancestor with `ClipsDescendants`.
## Implementation
```luau
--!strict
-- TutorialSpotlight.luau (client)
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local DIM_TRANSPARENCY = 0.35 -- 0 is solid black, 1 is no dimming
local PADDING = 6 -- pixels of space around the target
local function newStroke(parent: Instance, color: Color3, thickness: number, transparency: number, zIndex: number)
local stroke = Instance.new("UIStroke")
stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
stroke.BorderStrokePosition = Enum.BorderStrokePosition.Outer
stroke.Color = color
stroke.Thickness = thickness
stroke.Transparency = transparency
stroke.ZIndex = zIndex -- higher renders above lower
stroke.Parent = parent
return stroke
end
local function spotlight(target: GuiObject, message: string): () -> ()
local gui = Instance.new("ScreenGui")
gui.Name = "TutorialSpotlight"
gui.ScreenInsets = Enum.ScreenInsets.None
gui.SafeAreaCompatibility = Enum.SafeAreaCompatibility.None
gui.DisplayOrder = 1000
gui.ResetOnSpawn = false
local root = Instance.new("Frame")
root.Name = "Root"
root.BackgroundTransparency = 1
root.Size = UDim2.fromScale(1, 1)
root.Parent = gui
local hole = Instance.new("Frame")
hole.Name = "Hole"
hole.BackgroundTransparency = 1
hole.Parent = root
newStroke(hole, Color3.new(0, 0, 0), 10000, DIM_TRANSPARENCY, 1) -- the dim
newStroke(hole, Color3.new(1, 1, 1), 3, 0, 2) -- bright outline
local corner = target:FindFirstChildOfClass("UICorner")
if corner then
corner:Clone().Parent = hole -- round the hole like the target
end
local label = Instance.new("TextLabel")
label.AutomaticSize = Enum.AutomaticSize.XY
label.BackgroundTransparency = 1
label.Text = message
label.TextColor3 = Color3.new(1, 1, 1)
label.TextSize = 24
label.ZIndex = 3
label.Parent = root
gui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
local connection: RBXScriptConnection
local function stop()
connection:Disconnect()
gui:Destroy()
end
connection = RunService.PreRender:Connect(function()
if not target:IsDescendantOf(game) then
stop()
return
end
local pos = target.AbsolutePosition - root.AbsolutePosition - Vector2.new(PADDING, PADDING)
local size = target.AbsoluteSize + Vector2.new(PADDING * 2, PADDING * 2)
hole.Position = UDim2.fromOffset(pos.X, pos.Y)
hole.Size = UDim2.fromOffset(size.X, size.Y)
-- Label below the hole if there is room, otherwise above; bob it gently
local bob = math.sin(os.clock() * 4) * 6
local below = pos.Y + size.Y + 80 < root.AbsoluteSize.Y
label.AnchorPoint = Vector2.new(0.5, if below then 0 else 1)
label.Position = UDim2.fromOffset(
pos.X + size.X / 2,
if below then pos.Y + size.Y + 16 + bob else pos.Y - 16 + bob
)
end)
return stop
end
return spotlight
```
Usage in a tutorial step:
```luau
local stop = spotlight(shopButton, "Open the shop")
shopButton.Activated:Once(function()
stop()
nextStep()
end)
```
Add an arrow the same way as the label: an `ImageLabel` with your own uploaded arrow image, positioned from `pos` and
`size` in the same loop. Don't guess asset ids.
## Blocking clicks outside the target
The stroke only draws; clicks on the dimmed area still reach whatever is underneath. To allow only the target, add
four invisible buttons around the hole (buttons on top take the click):
```luau
local function blocker(parent: Instance): TextButton
local button = Instance.new("TextButton")
button.Text = ""
button.AutoButtonColor = false
button.BackgroundTransparency = 1
button.Parent = parent
return button
end
local top, bottom, left, right = blocker(root), blocker(root), blocker(root), blocker(root)
-- inside the PreRender loop, after pos and size:
local screen = root.AbsoluteSize
top.Size = UDim2.fromOffset(screen.X, math.max(pos.Y, 0))
bottom.Position = UDim2.fromOffset(0, pos.Y + size.Y)
bottom.Size = UDim2.fromOffset(screen.X, math.max(screen.Y - pos.Y - size.Y, 0))
left.Position = UDim2.fromOffset(0, pos.Y)
left.Size = UDim2.fromOffset(math.max(pos.X, 0), size.Y)
right.Position = UDim2.fromOffset(pos.X + size.X, pos.Y)
right.Size = UDim2.fromOffset(math.max(screen.X - pos.X - size.X, 0), size.Y)
```
For gamepad players, also move selection to the target: `GuiService.SelectedObject = target` (the target must be
`Selectable`).
## Variations
- **Pulse:** tween the outline stroke's `Transparency` or `Thickness`, not the 10000-pixel dim stroke.
- **Fade in:** tween the dim stroke's `Transparency` from 1 to `DIM_TRANSPARENCY`.
- **3D targets** (an NPC, a door): use a `Highlight` instance with `DepthMode = Enum.HighlightDepthMode.AlwaysOnTop`
on the model, plus a `BillboardGui` label. The UIStroke trick is for 2D UI.
## Mistakes to avoid
- Hard-coding the topbar height (36, 58...) or adding `GuiService:GetGuiInset()` by hand. Subtract the overlay
root's `AbsolutePosition` instead.
- Leaving the overlay at the default `ScreenInsets`, so a strip at the top (and the notch area) stays undimmed.
- Computing the position once. Layouts, tweens, scrolling and rotation move the target; update every frame.
- A `Thickness` that is too small for large monitors. 10000 covers any screen.
- Expecting the dim to block input. It doesn't; use blockers if the step requires one specific click.
- Forgetting to clean up: disconnect the PreRender connection and destroy the overlay when the step ends.
- Targets inside rotated frames. The hole is axis-aligned and won't match a rotated element.