All modules

Luau · ModuleScript · UI

UIEffects

Looping ambient UI motion. One stop() call tears down every effect on an instance.

What it is

Tween libraries cover one shot motion. Show, hide, hover, pop. UIEffects owns the motion that keeps running until you stop it. Gradient spins, floats, glows, shimmers, shine sweeps, colour cycles, sparkles, and attention nudges.

See it move

These are the same ideas the module drives in Studio. Watch the loop, then drop the matching call on your GuiObject.

VIP

rainbowStroke

Spin a stroke gradient. Authored colours stay put unless you pass a palette.

float

Tilt, rise, and breathe together. The hero icon look.

Buy

shine

A bright band sweeps any GuiObject. Overlay matches corners and padding.

sparkle

Twinkling glints around the edges. Event chained, no polling loop.

LEGENDARY

colorCycle

Blend a Color3 property through a palette. Gold, Fire, Ice, and more.

glow

Pulse a UIStroke transparency or thickness until you cancel it.

shimmer

Sweep a light band across a gradient you already have.

$

attention

Periodic pop, then shake. A HUD nudge players actually notice.

Install

Put UIEffects.luau in ReplicatedStorage as a ModuleScript. Client only. No extra dependencies.

LocalScript

local UIEffects = require(game.ReplicatedStorage.UIEffects)

Usage

Start effects when a panel opens. Stop them when it closes. Keep a cancel handle only if you want one effect gone and the rest still running.

usage.luau

local UIEffects = require(game.ReplicatedStorage.UIEffects)

UIEffects.rainbowStroke(card.UIStroke)

UIEffects.float(shopIcon)
UIEffects.sparkle(shopIcon, { count = 7, tint = Color3.fromRGB(255, 220, 120) })

UIEffects.shine(buyButton, { seconds = 1.2, delay = 2 })
UIEffects.colorCycle(titleLabel, "TextColor3", "Gold", { seconds = 2 })
UIEffects.attention(shopIcon, { interval = 30 })

UIEffects.stop(shopIcon)
UIEffects.stop(card.UIStroke)

one-effect.luau

local stopGlow = UIEffects.glow(stroke)
stopGlow()

The API

gradientSpin loops a UIGradient. rainbowStroke and godlyStroke palette a UIStroke and spin it. shimmer sweeps a band on a gradient you already have. shine builds its own overlay on any GuiObject.

float, bob, sway, spin, and breathe cover motion. glow pulses a stroke. sparkle plants glints. attention pops then shakes. colorCycle blends a Color3 property through a palette.

Palettes are Rainbow, Gold, Fire, Ice, Toxic, Royal, Sunset, and Ghost. Pass a name, a Color3 array, or a ColorSequence.

Full source

UIEffects.luau

--!nonstrict
--[[
	UIEffects — persistent / looping ambient UI effects.

	One-shot motion (show / hide / hover / pop) is not this module's job. UIEffects owns
	ONGOING motion that runs until you stop it: gradient spins, floats, glows, shimmers,
	shine sweeps, colour cycles, sparkles, attention nudges.

	Contract:
	  • Every effect returns a `cancel` function — call it to stop that one effect.
	  • Every effect self-registers against its target instance, so a single
	    UIEffects.stop(obj) tears down EVERY effect running on that instance.
	    Call it in your panel-close / cleanup path.
	  • Zero per-frame Lua. Each effect is one looping/reversing TweenInfo or an
	    event-chained tween (Completed → next). No task.spawn + task.wait polling.
	  • Every default lives in the CONFIG table below; every effect takes an `opts`
	    table that overrides them per call. Nothing is hardcoded in the effect bodies.

	API:
	    UIEffects.Palettes                                   -- { name -> { Color3 } }
	    UIEffects.sequence(palette)                          -- palette/array/ColorSequence -> ColorSequence
	    UIEffects.applyPalette(gradient, palette)            -- set gradient.Color (no animation)

	    UIEffects.gradientSpin(gradient, opts?)  -> cancel   -- loop UIGradient.Rotation 0→360
	    UIEffects.rainbowStroke(stroke, opts?)   -> cancel   -- palette a UIStroke's gradient + spin it
	    UIEffects.godlyStroke(stroke, opts?)     -> cancel   -- rainbowStroke at the "hero item" speed
	    UIEffects.shimmer(gradient, opts?)       -> cancel   -- sweep a light band across a gradient
	    UIEffects.shine(obj, opts?)              -> cancel   -- shine sweep over ANY GuiObject
	    UIEffects.colorCycle(inst, property, palette, opts?) -> cancel

	    UIEffects.float(obj, opts?)     -> cancel            -- tilt + rise + breathe
	    UIEffects.bob(obj, opts?)       -> cancel            -- vertical bob only
	    UIEffects.sway(obj, opts?)      -> cancel            -- rotation wobble only
	    UIEffects.spin(obj, opts?)      -> cancel            -- continuous full rotation
	    UIEffects.breathe(obj, opts?)   -> cancel            -- UIScale pulse in/out
	    UIEffects.glow(stroke, opts?)   -> cancel            -- pulse a UIStroke Transparency/Thickness
	    UIEffects.sparkle(host, opts?)  -> cancel            -- twinkling glints around a host's edges
	    UIEffects.attention(obj, opts?) -> cancel            -- periodic pop + shake "look at me" burst

	    UIEffects.stop(inst)                                 -- cancel ALL effects on an instance

	Client-side module. Put it in ReplicatedStorage (or StarterPlayerScripts) and require it.

	`shine` is adapted from Shime by RyanLua (Apache-2.0).
]]

local TweenService = game:GetService("TweenService")

--====================================================================================
--// CONFIG — every default lives here. Per-call `opts` override any of these.
--====================================================================================
local CONFIG = {
	-- Durations (seconds)
	GRADIENT_SPIN_SECONDS = 6,    -- one full 360° rotation of a UIGradient
	GODLY_SPIN_SECONDS    = 6,    -- godlyStroke's spin speed (your "hero item" look)
	FLOAT_SECONDS         = 2.6,  -- half-cycle of a float (up↔down); full cycle is 2x
	BOB_SECONDS           = 1.8,  -- half-cycle of a vertical bob
	SWAY_SECONDS          = 2.0,  -- half-cycle of a rotation sway
	SPIN_SECONDS          = 3.0,  -- one full 360° rotation of a GuiObject
	BREATHE_SECONDS       = 1.2,  -- half-cycle of a UIScale breathe
	GLOW_SECONDS          = 0.9,  -- half-cycle of a UIStroke glow pulse
	SHIMMER_SECONDS       = 1.4,  -- one sweep of a shimmer band across a gradient
	COLOR_CYCLE_SECONDS   = 1.5,  -- blend time between two palette colours

	-- Amplitudes
	FLOAT_ROTATION = 8,      -- degrees of tilt each way
	FLOAT_RISE     = 0.015,  -- Position.Y.Scale offset each way
	FLOAT_SCALE    = 0.06,   -- ± size multiplier at the extremes
	BOB_RISE       = 0.02,   -- Position.Y.Scale offset each way
	SWAY_ROTATION  = 6,      -- degrees of tilt each way
	BREATHE_SCALE  = 0.06,   -- ± UIScale at the extremes
	GLOW_MIN_TRANSPARENCY = 0.1, -- UIStroke.Transparency at the bright end
	GLOW_MAX_TRANSPARENCY = 0.7, -- UIStroke.Transparency at the dim end
	SHIMMER_WIDTH  = 0.16,   -- half-width of the shimmer band (0..0.49 of the gradient)

	-- Sparkle: glints scattered around a host's edges. Each glint runs an event-chained
	-- lifecycle — pop-in → hold → fade-out → reposition → repeat. No polling loop.
	Sparkle = {
		IMAGE          = "rbxassetid://110129162476688", -- white glint texture (swap for your own)
		COUNT          = 5,     -- glints per host
		TINT           = Color3.fromRGB(255, 255, 255),
		MIN_SIZE       = 0.11,  -- Scale size lower bound (random per glint)
		MAX_SIZE       = 0.17,  -- Scale size upper bound
		IN_SECONDS     = 0.4,   -- pop-in / fade-in duration
		OUT_SECONDS    = 0.4,   -- fade-out duration
		HOLD_SECONDS   = 0.8,   -- visible hold (baked into the out-tween DelayTime)
		GAP_SECONDS    = 0.5,   -- invisible gap before the next pop (in-tween DelayTime)
		STAGGER        = 0.18,  -- per-glint start offset so they don't pulse in sync
		PEAK_TRANSPARENCY = 0.0, -- ImageTransparency at the brightest point
		ROTATION_RANGE = 15,    -- ± degrees random rotation on each reposition
		EDGE_INSET     = 0.06,  -- how far inside the edge glints may sit (0 = on the edge)
		ZINDEX_OFFSET  = 1,     -- drawn just above the host's content
	},

	-- Shine: a bright band sweeps diagonally across a GuiObject via an auto-created
	-- clipped overlay that matches the host's UICorner / UIPadding. One looping tween.
	Shine = {
		SECONDS       = 1.0,    -- one sweep across
		ROTATION      = 15,     -- gradient rotation → diagonal shine
		PEAK          = 0.55,   -- band-centre transparency (lower = brighter)
		SPREAD        = 0.35,   -- half-width of the visible band (0..0.49)
		COLOR         = Color3.fromRGB(255, 255, 255),
		DELAY         = 0,      -- pause between sweeps (baked into the tween DelayTime)
		REVERSES      = false,  -- sweep one way and restart (true = ping-pong)
		ZINDEX_OFFSET = 5,      -- draw the shine above the host's content
	},

	-- Attention nudge: every INTERVAL seconds, pop the object up (UIScale) and back,
	-- then shake it (Rotation) for SHAKE_SECONDS, then rest. Fully event-chained.
	Attention = {
		ENABLED       = true,  -- master kill-switch (false makes attention() a no-op)
		INTERVAL      = 25,    -- seconds between burst starts (pop + shake + rest)
		POP_SCALE     = 1.18,  -- UIScale peak of the size-up pop
		POP_SECONDS   = 0.16,  -- duration of the size-up AND the size-back (each)
		SHAKE_SECONDS = 5,     -- how long the object shakes after the pop
		SHAKE_ANGLE   = 30,    -- ± degrees
		SHAKE_PERIOD  = 0.12,  -- seconds per half-swing (~4 wiggles/sec)
		FIRST_DELAY   = 3,     -- delay before the FIRST burst
	},
}

local cfg = CONFIG

local Linear = Enum.EasingStyle.Linear
local Sine   = Enum.EasingStyle.Sine
local Quad   = Enum.EasingStyle.Quad
local Back   = Enum.EasingStyle.Back
local In     = Enum.EasingDirection.In
local Out    = Enum.EasingDirection.Out
local InOut  = Enum.EasingDirection.InOut

--====================================================================================
--// COLOUR PALETTES (ordered Color3 stops)
--   Palettes meant for a SPINNING gradient repeat their first colour at the end so the
--   0° ≡ 360° wrap is seamless (Rainbow, Fire, … start and end on the same hue).
--====================================================================================
local rgb = Color3.fromRGB

local Palettes: { [string]: { Color3 } } = {
	Rainbow = { rgb(255, 0, 0), rgb(255, 165, 0), rgb(255, 255, 0), rgb(0, 200, 60),
	            rgb(0, 180, 255), rgb(90, 60, 255), rgb(200, 0, 255), rgb(255, 0, 0) },
	Gold    = { rgb(120, 80, 0), rgb(255, 200, 60), rgb(255, 245, 200), rgb(255, 200, 60), rgb(120, 80, 0) },
	Fire    = { rgb(120, 0, 0), rgb(255, 60, 0), rgb(255, 180, 0), rgb(255, 60, 0), rgb(120, 0, 0) },
	Ice     = { rgb(0, 90, 160), rgb(90, 210, 255), rgb(220, 250, 255), rgb(90, 210, 255), rgb(0, 90, 160) },
	Toxic   = { rgb(20, 90, 0), rgb(120, 255, 40), rgb(220, 255, 150), rgb(120, 255, 40), rgb(20, 90, 0) },
	Royal   = { rgb(40, 0, 90), rgb(120, 40, 255), rgb(60, 120, 255), rgb(120, 40, 255), rgb(40, 0, 90) },
	Sunset  = { rgb(60, 0, 90), rgb(255, 60, 120), rgb(255, 170, 60), rgb(255, 60, 120), rgb(60, 0, 90) },
	Ghost   = { rgb(120, 200, 220), rgb(255, 255, 255), rgb(120, 200, 220) },
}

--====================================================================================
--// EFFECT TRACKING (weak-keyed so a destroyed instance is GC'd with its cancels)
--====================================================================================
local active: { [Instance]: { [() -> ()]: boolean } } = setmetatable({}, { __mode = "k" }) :: any

-- Wrap a raw cancel so it deregisters itself, and store it under the instance.
local function register(inst: Instance, rawCancel: () -> ()): () -> ()
	local set = active[inst]
	if not set then
		set = {}
		active[inst] = set
	end
	local cancel
	cancel = function()
		if set[cancel] == nil then return end -- already cancelled
		set[cancel] = nil
		rawCancel()
	end
	set[cancel] = true
	return cancel
end

--====================================================================================
--// INTERNAL HELPERS
--====================================================================================
local function toSequence(colors: { Color3 }): ColorSequence
	local n = #colors
	if n <= 1 then
		return ColorSequence.new(colors[1] or Color3.new(1, 1, 1))
	end
	local kps = table.create(n)
	for i, c in colors do
		kps[i] = ColorSequenceKeypoint.new((i - 1) / (n - 1), c)
	end
	return ColorSequence.new(kps)
end

-- Resolve a palette argument to an ordered { Color3 } list.
local function resolveColors(palette: any): { Color3 }
	if type(palette) == "string" then
		return Palettes[palette] or Palettes.Rainbow
	elseif type(palette) == "table" then
		return palette :: { Color3 }
	end
	return Palettes.Rainbow
end

local function getOrCreate(parent: Instance, className: string): Instance
	local found = parent:FindFirstChildOfClass(className)
	if found then return found end
	local inst = Instance.new(className)
	inst.Parent = parent
	return inst
end

local function getOrCreateUIScale(obj: GuiObject): UIScale
	return getOrCreate(obj, "UIScale") :: UIScale
end

-- A random scaled point along one of the four edges (with an inset from the corners).
local function randomEdgePosition(inset: number): UDim2
	local lo, hi = inset, 1 - inset
	local along = lo + math.random() * (hi - lo)
	local edge = math.random(1, 4)
	if edge == 1 then return UDim2.fromScale(along, lo)      -- top
	elseif edge == 2 then return UDim2.fromScale(hi, along)  -- right
	elseif edge == 3 then return UDim2.fromScale(along, hi)  -- bottom
	else return UDim2.fromScale(lo, along) end               -- left
end

--====================================================================================
--// PUBLIC API
--====================================================================================
local UIEffects = {}

UIEffects.Config = CONFIG
UIEffects.Palettes = Palettes

-- Convert a palette name / Color3 array / ColorSequence into a ColorSequence.
function UIEffects.sequence(palette: any): ColorSequence
	if typeof(palette) == "ColorSequence" then
		return palette
	end
	return toSequence(resolveColors(palette))
end

-- Set a UIGradient's Color to a preset palette (no animation). Returns the gradient.
function UIEffects.applyPalette(gradient: UIGradient, palette: any): UIGradient
	gradient.Color = UIEffects.sequence(palette)
	return gradient
end

--------------------------------------------------------------------------------------
-- GRADIENT SPIN — loop UIGradient.Rotation 0 → 360 forever (seamless, 360 ≡ 0).
--   opts: { seconds: number?, clockwise: boolean? }
--------------------------------------------------------------------------------------
type SpinOpts = { seconds: number?, clockwise: boolean? }
function UIEffects.gradientSpin(gradient: UIGradient, opts: SpinOpts?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.GRADIENT_SPIN_SECONDS
	local goal = if o.clockwise == false then -360 else 360
	gradient.Rotation = 0
	local tw = TweenService:Create(gradient, TweenInfo.new(seconds, Linear, InOut, -1), { Rotation = goal })
	tw:Play()
	return register(gradient, function()
		tw:Cancel()
		gradient.Rotation = 0
	end)
end

--------------------------------------------------------------------------------------
-- RAINBOW STROKE — palette a UIStroke's gradient and spin it. Reuses the stroke's
-- existing UIGradient (e.g. one you authored in Studio) or creates one.
-- Colour rule: if `opts.palette` is given, apply it; otherwise PRESERVE an authored
-- gradient's colours (spin only) and apply the default Rainbow palette solely when a new
-- gradient had to be created. Hand-authored strokes keep looking exactly as designed.
--   opts: { palette: (string | {Color3} | ColorSequence)?, seconds: number?, clockwise: boolean? }
--------------------------------------------------------------------------------------
type RainbowOpts = { palette: any?, seconds: number?, clockwise: boolean? }
function UIEffects.rainbowStroke(stroke: UIStroke, opts: RainbowOpts?): () -> ()
	local o = opts or {}
	local gradient = stroke:FindFirstChildOfClass("UIGradient") :: UIGradient?
	local created = false
	if not gradient then
		gradient = Instance.new("UIGradient")
		gradient.Parent = stroke
		created = true
	end
	if o.palette ~= nil then
		UIEffects.applyPalette(gradient :: UIGradient, o.palette)
	elseif created then
		UIEffects.applyPalette(gradient :: UIGradient, "Rainbow")
	end -- else: preserve the authored gradient's colours
	local stopSpin = UIEffects.gradientSpin(gradient :: UIGradient, { seconds = o.seconds, clockwise = o.clockwise })
	-- Register against the STROKE too, so UIEffects.stop(stroke) also stops the spin.
	return register(stroke, stopSpin)
end

--------------------------------------------------------------------------------------
-- GODLY STROKE — rainbowStroke pinned to your "hero item" spin speed
-- (CONFIG.GODLY_SPIN_SECONDS), so every top-rarity card in the game spins in lockstep
-- and re-timing them all is one config edit. Preserves authored colours.
--   opts: { seconds: number?, clockwise: boolean? }
--------------------------------------------------------------------------------------
function UIEffects.godlyStroke(stroke: UIStroke, opts: { seconds: number?, clockwise: boolean? }?): () -> ()
	local o = opts or {}
	return UIEffects.rainbowStroke(stroke, {
		seconds = o.seconds or cfg.GODLY_SPIN_SECONDS,
		clockwise = o.clockwise,
	})
end

--------------------------------------------------------------------------------------
-- SHIMMER — sweep a bright band across a gradient via its Offset. Sets a Transparency
-- band (opaque → clear → opaque) and animates Offset.X from -1 → 1.
--   opts: { seconds: number?, width: number?, vertical: boolean? }
--------------------------------------------------------------------------------------
type ShimmerOpts = { seconds: number?, width: number?, vertical: boolean? }
function UIEffects.shimmer(gradient: UIGradient, opts: ShimmerOpts?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.SHIMMER_SECONDS
	local w = math.clamp(o.width or cfg.SHIMMER_WIDTH, 0.01, 0.49)
	gradient.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(0.5 - w, 1),
		NumberSequenceKeypoint.new(0.5, 0),
		NumberSequenceKeypoint.new(0.5 + w, 1),
		NumberSequenceKeypoint.new(1, 1),
	})
	local from = if o.vertical then Vector2.new(0, -1) else Vector2.new(-1, 0)
	local to = if o.vertical then Vector2.new(0, 1) else Vector2.new(1, 0)
	gradient.Offset = from
	local tw = TweenService:Create(gradient, TweenInfo.new(seconds, Sine, InOut, -1), { Offset = to })
	tw:Play()
	return register(gradient, function()
		tw:Cancel()
		gradient.Offset = Vector2.new(0, 0)
		gradient.Transparency = NumberSequence.new(0)
	end)
end

--------------------------------------------------------------------------------------
-- SHINE — a bright band sweeps diagonally across ANY GuiObject.
-- Creates a clipped overlay that matches the host's UICorner (and UIPadding), with a
-- gradient whose transparency band sweeps via Offset -1 → 1 on one looping tween. Unlike
-- `shimmer` (which animates a gradient you already have), this works on any object and
-- auto-shapes itself. Adapted from Shime by RyanLua (Apache-2.0).
--   opts: { seconds?, rotation?, peak?, spread?, color?, delay?, reverses?, zindexOffset? }
--------------------------------------------------------------------------------------
type ShineOpts = {
	seconds: number?, rotation: number?, peak: number?, spread: number?,
	color: Color3?, delay: number?, reverses: boolean?, zindexOffset: number?,
}
function UIEffects.shine(obj: GuiObject, opts: ShineOpts?): () -> ()
	local o = opts or {}
	local s = cfg.Shine
	local seconds  = o.seconds or s.SECONDS
	local rotation = o.rotation or s.ROTATION
	local peak     = o.peak or s.PEAK
	local spread   = math.clamp(o.spread or s.SPREAD, 0.01, 0.49)
	local color    = o.color or s.COLOR
	local delay    = o.delay or s.DELAY
	local reverses = if o.reverses ~= nil then o.reverses else s.REVERSES
	local zOffset  = o.zindexOffset or s.ZINDEX_OFFSET

	-- Clipped overlay that fills the host.
	local frame = Instance.new("Frame")
	frame.Name = "UIEffectShine"
	frame.BackgroundColor3 = color
	frame.BackgroundTransparency = 0
	frame.ClipsDescendants = true
	frame.BorderSizePixel = 0
	frame.AnchorPoint = Vector2.new(0.5, 0.5)
	frame.Position = UDim2.fromScale(0.5, 0.5)
	frame.Size = UDim2.fromScale(1, 1)
	frame.ZIndex = obj.ZIndex + zOffset

	-- Match the host's rounded corners (and keep them in sync if animated).
	local parentCorner = obj:FindFirstChildOfClass("UICorner")
	if parentCorner then
		local corner = Instance.new("UICorner")
		corner.CornerRadius = parentCorner.CornerRadius
		corner.Parent = frame
		parentCorner:GetPropertyChangedSignal("CornerRadius"):Connect(function()
			if corner.Parent then corner.CornerRadius = parentCorner.CornerRadius end
		end)
	end

	-- Grow the overlay to cover the host's padded area (computed once — padding rarely animates).
	local padding = obj:FindFirstChildOfClass("UIPadding")
	if padding then
		local wScale = padding.PaddingLeft.Scale + padding.PaddingRight.Scale
		local hScale = padding.PaddingTop.Scale + padding.PaddingBottom.Scale
		local wOff = padding.PaddingLeft.Offset + padding.PaddingRight.Offset
		local hOff = padding.PaddingTop.Offset + padding.PaddingBottom.Offset
		local wDiff = padding.PaddingLeft.Offset - padding.PaddingRight.Offset
		local hDiff = padding.PaddingTop.Offset - padding.PaddingBottom.Offset
		frame.Size = UDim2.new(1 / (1 - wScale), wOff, 1 / (1 - hScale), hOff)
		frame.Position = UDim2.new(0.5, -wDiff / 2, 0.5, -hDiff / 2)
	end

	local gradient = Instance.new("UIGradient")
	gradient.Rotation = rotation
	gradient.Color = ColorSequence.new(color)
	gradient.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(math.max(0.001, 0.5 - spread), 1),
		NumberSequenceKeypoint.new(0.5, peak),
		NumberSequenceKeypoint.new(math.min(0.999, 0.5 + spread), 1),
		NumberSequenceKeypoint.new(1, 1),
	})
	gradient.Offset = Vector2.new(-1, 0)
	gradient.Parent = frame
	frame.Parent = obj

	local tw = TweenService:Create(gradient, TweenInfo.new(seconds, Linear, InOut, -1, reverses, delay), { Offset = Vector2.new(1, 0) })
	tw:Play()

	return register(obj, function()
		tw:Cancel()
		frame:Destroy()
	end)
end

--------------------------------------------------------------------------------------
-- COLOR CYCLE — cycle a Color3 property through a palette, blending between stops.
-- Event-chained (each tween's Completed starts the next) — no polling loop.
--   inst: any instance with a Color3 property (TextLabel, ImageLabel, Frame, UIStroke…)
--   property: e.g. "TextColor3", "ImageColor3", "BackgroundColor3", "Color"
--   opts: { seconds: number? }  -- blend time between adjacent stops
--------------------------------------------------------------------------------------
function UIEffects.colorCycle(inst: Instance, property: string, palette: any, opts: { seconds: number? }?): () -> ()
	local o = opts or {}
	local colors = resolveColors(palette)
	local info = TweenInfo.new(o.seconds or cfg.COLOR_CYCLE_SECONDS, Sine, InOut)
	local running = true
	local idx = 1
	local current: Tween? = nil

	;(inst :: any)[property] = colors[idx]

	local function step()
		if not running then return end
		idx = idx % #colors + 1
		current = TweenService:Create(inst, info, { [property] = colors[idx] })
		current:Play()
		current.Completed:Once(function(state)
			if running and state == Enum.PlaybackState.Completed then
				step()
			end
		end)
	end
	step()

	return register(inst, function()
		running = false
		if current then current:Cancel() end
	end)
end

--------------------------------------------------------------------------------------
-- FLOAT — tilt + rise + breathe forever (the "hero icon" effect). Oscillates Rotation,
-- Position.Y and Size between an UP and DOWN state via one reversing tween.
--   opts: { seconds: number?, rotation: number?, rise: number?, scale: number? }
--------------------------------------------------------------------------------------
type FloatOpts = { seconds: number?, rotation: number?, rise: number?, scale: number? }
function UIEffects.float(obj: GuiObject, opts: FloatOpts?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.FLOAT_SECONDS
	local rot = o.rotation or cfg.FLOAT_ROTATION
	local rise = o.rise or cfg.FLOAT_RISE
	local scaleAmt = o.scale or cfg.FLOAT_SCALE

	local origSize, origPos, origRot = obj.Size, obj.Position, obj.Rotation
	local function scaled(f: number): UDim2
		return UDim2.new(origSize.X.Scale * f, origSize.X.Offset * f, origSize.Y.Scale * f, origSize.Y.Offset * f)
	end

	-- Start at the UP state, then oscillate UP↔DOWN forever via Reverses.
	obj.Rotation = origRot + rot
	obj.Size = scaled(1 + scaleAmt)
	obj.Position = UDim2.new(origPos.X.Scale, origPos.X.Offset, origPos.Y.Scale - rise, origPos.Y.Offset)

	local tw = TweenService:Create(obj, TweenInfo.new(seconds, Sine, InOut, -1, true), {
		Rotation = origRot - rot,
		Size = scaled(1 - scaleAmt),
		Position = UDim2.new(origPos.X.Scale, origPos.X.Offset, origPos.Y.Scale + rise, origPos.Y.Offset),
	})
	tw:Play()

	return register(obj, function()
		tw:Cancel()
		obj.Rotation, obj.Size, obj.Position = origRot, origSize, origPos
	end)
end

--------------------------------------------------------------------------------------
-- BOB — vertical Position oscillation only.  opts: { seconds?, rise? }
--------------------------------------------------------------------------------------
function UIEffects.bob(obj: GuiObject, opts: { seconds: number?, rise: number? }?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.BOB_SECONDS
	local rise = o.rise or cfg.BOB_RISE
	local origPos = obj.Position
	obj.Position = UDim2.new(origPos.X.Scale, origPos.X.Offset, origPos.Y.Scale - rise, origPos.Y.Offset)
	local tw = TweenService:Create(obj, TweenInfo.new(seconds, Sine, InOut, -1, true), {
		Position = UDim2.new(origPos.X.Scale, origPos.X.Offset, origPos.Y.Scale + rise, origPos.Y.Offset),
	})
	tw:Play()
	return register(obj, function()
		tw:Cancel()
		obj.Position = origPos
	end)
end

--------------------------------------------------------------------------------------
-- SWAY — Rotation wobble only.  opts: { seconds?, rotation? }
--------------------------------------------------------------------------------------
function UIEffects.sway(obj: GuiObject, opts: { seconds: number?, rotation: number? }?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.SWAY_SECONDS
	local rot = o.rotation or cfg.SWAY_ROTATION
	local origRot = obj.Rotation
	obj.Rotation = origRot - rot
	local tw = TweenService:Create(obj, TweenInfo.new(seconds, Sine, InOut, -1, true), { Rotation = origRot + rot })
	tw:Play()
	return register(obj, function()
		tw:Cancel()
		obj.Rotation = origRot
	end)
end

--------------------------------------------------------------------------------------
-- SPIN — continuous full rotation of a GuiObject (sunrays, badges).
--   opts: { seconds?, clockwise? }
--------------------------------------------------------------------------------------
function UIEffects.spin(obj: GuiObject, opts: { seconds: number?, clockwise: boolean? }?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.SPIN_SECONDS
	local origRot = obj.Rotation
	local goal = origRot + (if o.clockwise == false then -360 else 360)
	local tw = TweenService:Create(obj, TweenInfo.new(seconds, Linear, InOut, -1), { Rotation = goal })
	tw:Play()
	return register(obj, function()
		tw:Cancel()
		obj.Rotation = origRot
	end)
end

--------------------------------------------------------------------------------------
-- BREATHE — UIScale pulse in/out (a soft "alive" heartbeat). opts: { seconds?, scale? }
--------------------------------------------------------------------------------------
function UIEffects.breathe(obj: GuiObject, opts: { seconds: number?, scale: number? }?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.BREATHE_SECONDS
	local amt = o.scale or cfg.BREATHE_SCALE
	local uiScale = getOrCreateUIScale(obj)
	uiScale.Scale = 1 - amt
	local tw = TweenService:Create(uiScale, TweenInfo.new(seconds, Sine, InOut, -1, true), { Scale = 1 + amt })
	tw:Play()
	return register(obj, function()
		tw:Cancel()
		uiScale.Scale = 1
	end)
end

--------------------------------------------------------------------------------------
-- GLOW — pulse a UIStroke property (Transparency by default, or Thickness).
--   opts: { seconds?, min?, max?, property? }
--------------------------------------------------------------------------------------
type GlowOpts = { seconds: number?, min: number?, max: number?, property: string? }
function UIEffects.glow(stroke: UIStroke, opts: GlowOpts?): () -> ()
	local o = opts or {}
	local seconds = o.seconds or cfg.GLOW_SECONDS
	local property = o.property or "Transparency"
	local min = o.min or cfg.GLOW_MIN_TRANSPARENCY
	local max = o.max or cfg.GLOW_MAX_TRANSPARENCY
	local original = (stroke :: any)[property]
	;(stroke :: any)[property] = min
	local tw = TweenService:Create(stroke, TweenInfo.new(seconds, Sine, InOut, -1, true), { [property] = max })
	tw:Play()
	return register(stroke, function()
		tw:Cancel()
		;(stroke :: any)[property] = original
	end)
end

--------------------------------------------------------------------------------------
-- SPARKLE — scatter `count` twinkling glints around a host's edges. Each glint runs an
-- event-chained lifecycle: pop-in → hold → fade-out → reposition → repeat, FOREVER, with
-- zero polling. The hold/gap delays are baked into each tween's DelayTime. Glints are
-- ImageLabels parented to `host`, destroyed on cancel / UIEffects.stop(host).
--   opts: { count?, tint?, minSize?, maxSize?, inSeconds?, outSeconds?, hold?, gap?,
--           stagger?, peakTransparency?, rotationRange?, edgeInset?, zindexOffset?, image? }
--------------------------------------------------------------------------------------
type SparkleOpts = {
	count: number?, tint: Color3?, minSize: number?, maxSize: number?,
	inSeconds: number?, outSeconds: number?, hold: number?, gap: number?,
	stagger: number?, peakTransparency: number?, rotationRange: number?,
	edgeInset: number?, zindexOffset: number?, image: string?,
}
function UIEffects.sparkle(host: GuiObject, opts: SparkleOpts?): () -> ()
	local o = opts or {}
	local s = cfg.Sparkle
	local count        = o.count or s.COUNT
	local tint         = o.tint or s.TINT
	local minSize      = o.minSize or s.MIN_SIZE
	local maxSize      = o.maxSize or s.MAX_SIZE
	local inSeconds    = o.inSeconds or s.IN_SECONDS
	local outSeconds   = o.outSeconds or s.OUT_SECONDS
	local hold         = o.hold or s.HOLD_SECONDS
	local gap          = o.gap or s.GAP_SECONDS
	local stagger      = o.stagger or s.STAGGER
	local peak         = o.peakTransparency or s.PEAK_TRANSPARENCY
	local rotRange     = o.rotationRange or s.ROTATION_RANGE
	local inset        = o.edgeInset or s.EDGE_INSET
	local zOffset      = o.zindexOffset or s.ZINDEX_OFFSET
	local image        = o.image or s.IMAGE

	local cancelled = false
	local glints: { ImageLabel } = {}

	for i = 1, count do
		local glint = Instance.new("ImageLabel")
		glint.Name = "UIEffectSparkle"
		glint.Image = image
		glint.ImageColor3 = tint
		glint.ImageTransparency = 1
		glint.BackgroundTransparency = 1
		glint.AnchorPoint = Vector2.new(0.5, 0.5)
		glint.ZIndex = host.ZIndex + zOffset
		local sz = minSize + math.random() * (maxSize - minSize)
		glint.Size = UDim2.fromScale(sz, sz)
		local scale = Instance.new("UIScale")
		scale.Scale = 0
		scale.Parent = glint
		glint.Parent = host
		glints[i] = glint

		-- Event-chained lifecycle. The initial delay staggers the pops so glints desync.
		local function cycle(startDelay: number)
			if cancelled or glint.Parent == nil then return end
			-- Reposition + re-orient while invisible (scale 0, transparency 1).
			glint.Position = randomEdgePosition(inset)
			glint.Rotation = math.random(-rotRange, rotRange)

			local inInfo = TweenInfo.new(inSeconds, Back, Out, 0, false, startDelay)
			TweenService:Create(glint, TweenInfo.new(inSeconds, Quad, Out, 0, false, startDelay), { ImageTransparency = peak }):Play()
			local tIn = TweenService:Create(scale, inInfo, { Scale = 1 })
			tIn:Play()
			tIn.Completed:Once(function(state)
				if cancelled or state ~= Enum.PlaybackState.Completed then return end
				-- Fade-out after `hold` (hold baked into DelayTime — no task.wait).
				local outInfo = TweenInfo.new(outSeconds, Quad, In, 0, false, hold)
				TweenService:Create(glint, outInfo, { ImageTransparency = 1 }):Play()
				local tOut = TweenService:Create(scale, outInfo, { Scale = 0 })
				tOut:Play()
				tOut.Completed:Once(function(st)
					if cancelled or st ~= Enum.PlaybackState.Completed then return end
					cycle(gap) -- next cycle: invisible gap, then reposition + pop
				end)
			end)
		end
		cycle(gap + stagger * (i - 1))
	end

	return register(host, function()
		cancelled = true
		for _, glint in glints do
			glint:Destroy()
		end
		table.clear(glints)
	end)
end

--------------------------------------------------------------------------------------
-- ATTENTION — periodic "look at me" nudge for a HUD icon. Every `interval` seconds
-- (start-to-start): pop the object up (UIScale) and back, then shake it (Rotation) for
-- `shakeSeconds`, then rest. Event-chained (each phase's Completed starts the next; the
-- rest gap is baked into the pop-up tween's DelayTime) — one active tween at a time, zero
-- per-frame Lua. The live tween is tracked so cancel / UIEffects.stop halts mid-burst and
-- restores scale + rotation. No-op when CONFIG.Attention.ENABLED is false.
--   opts: { interval?, popScale?, popSeconds?, shakeSeconds?, shakeAngle?, shakePeriod?, firstDelay? }
--------------------------------------------------------------------------------------
type AttentionOpts = {
	interval: number?, popScale: number?, popSeconds: number?,
	shakeSeconds: number?, shakeAngle: number?, shakePeriod: number?, firstDelay: number?,
}
function UIEffects.attention(obj: GuiObject, opts: AttentionOpts?): () -> ()
	local a = cfg.Attention
	if not a or a.ENABLED == false then
		return function() end
	end

	local o = opts or {}
	local interval    = o.interval or a.INTERVAL
	local popScale    = o.popScale or a.POP_SCALE
	local popSeconds  = o.popSeconds or a.POP_SECONDS
	local shakeSecs   = o.shakeSeconds or a.SHAKE_SECONDS
	local shakeAngle  = o.shakeAngle or a.SHAKE_ANGLE
	local shakeHalf   = math.max(0.01, o.shakePeriod or a.SHAKE_PERIOD)
	local firstDelay  = o.firstDelay or a.FIRST_DELAY

	local uiScale = getOrCreateUIScale(obj)
	local origScale = uiScale.Scale
	local origRot = obj.Rotation

	-- Whole ±angle shake cycles that best fill shakeSecs (each cycle = 2 half-swings via Reverses).
	-- TweenInfo total with Reverses = Time * (RepeatCount + 1) * 2.
	local cycles = math.max(1, math.floor(shakeSecs / (2 * shakeHalf) + 0.5))
	local shakeInfo = TweenInfo.new(shakeHalf, Sine, InOut, cycles - 1, true)

	-- Idle rest = the interval minus the active pop+shake time, baked into the pop-up DelayTime.
	local activeTime = popSeconds * 2 + cycles * 2 * shakeHalf
	local restGap = math.max(0, interval - activeTime)

	local cancelled = false
	local current: Tween? = nil

	local function play(tween: Tween)
		current = tween
		tween:Play()
		return tween
	end

	local function cycle(startDelay: number)
		if cancelled or obj.Parent == nil then return end
		-- Do NOT force Scale/Rotation during the rest gap — hover effects own UIScale while
		-- idle. The pop tween's DelayTime covers the wait; it starts from whatever is live.

		-- 1) POP UP — waits `startDelay` (the rest gap) via DelayTime, then scales up.
		local up = play(TweenService:Create(
			uiScale,
			TweenInfo.new(popSeconds, Back, Out, 0, false, startDelay),
			{ Scale = origScale * popScale }
		))
		up.Completed:Once(function(st)
			if cancelled or st ~= Enum.PlaybackState.Completed then return end
			if obj.Parent == nil then return end
			-- 2) POP DOWN — back to rest scale.
			local down = play(TweenService:Create(
				uiScale,
				TweenInfo.new(popSeconds, Quad, Out),
				{ Scale = origScale }
			))
			down.Completed:Once(function(st2)
				if cancelled or st2 ~= Enum.PlaybackState.Completed then return end
				if obj.Parent == nil then return end
				-- 3) SHAKE — reversing rotation wobble for ~shakeSecs, then settle.
				obj.Rotation = origRot - shakeAngle
				local shake = play(TweenService:Create(obj, shakeInfo, { Rotation = origRot + shakeAngle }))
				shake.Completed:Once(function(st3)
					if cancelled then return end
					obj.Rotation = origRot
					if st3 == Enum.PlaybackState.Completed and obj.Parent ~= nil then
						cycle(restGap) -- 4) rest, then repeat
					end
				end)
			end)
		end)
	end
	cycle(firstDelay)

	return register(obj, function()
		cancelled = true
		if current then
			current:Cancel()
			current = nil
		end
		uiScale.Scale = origScale
		obj.Rotation = origRot
	end)
end

--------------------------------------------------------------------------------------
-- STOP — cancel EVERY effect registered against an instance (cleanup helper).
--------------------------------------------------------------------------------------
function UIEffects.stop(inst: Instance)
	local set = active[inst]
	if not set then return end
	-- Copy first: cancel() mutates `set`.
	local cancels = {}
	for cancel in set do
		table.insert(cancels, cancel)
	end
	for _, cancel in cancels do
		cancel()
	end
	active[inst] = nil
end

return UIEffects