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.
rainbowStroke
Spin a stroke gradient. Authored colours stay put unless you pass a palette.
float
Tilt, rise, and breathe together. The hero icon look.
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