The site is under maintenance, so logging in is temporarily unavailable. All resources and the MCP still work.

AI Skills

Tutorial spotlight highlights

By Roblox Vibecoders · roblox-tutorial-highlight

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.

Install it

Terminal
mkdir -p ~/.claude/skills/roblox-tutorial-highlight && curl -fsSL https://robloxvibecoders.com/skills/roblox-tutorial-highlight/SKILL.md -o ~/.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.