--!strict -- Toasts: a small toast queue. Clone an approved template (designed in HTML first), never build -- the look here. Dedupes identical text (restart + pulse instead of stacking), picks a duration -- from length, caps the stack, and closes up the gap when one leaves. local TweenService = game:GetService("TweenService") local Toasts = {} local MAX_STACK = 4 local GAP = 8 local SHORT, LONG, LONG_CHARS = 3.5, 6, 50 type Entry = { frame: Frame, text: string, token: number } local stack: { Entry } = {} local container: Frame local template: Frame function Toasts.init(parent: Frame, toastTemplate: Frame) container, template = parent, toastTemplate end local function relayout() local y = 0 for _, e in stack do TweenService:Create(e.frame, TweenInfo.new(0.2), { Position = UDim2.new(0.5, 0, 0, y) }):Play() y += e.frame.Size.Y.Offset + GAP -- template height is in offset (design px under a UIScale) end end local function remove(e: Entry) local i = table.find(stack, e) if not i then return end table.remove(stack, i) local t = TweenService:Create(e.frame, TweenInfo.new(0.15), { Size = UDim2.new(e.frame.Size.X, UDim.new(0, 0)) }) t.Completed:Once(function() e.frame:Destroy() end) t:Play() relayout() end local function arm(e: Entry, duration: number) e.token += 1 local my = e.token -- restart the countdown strip too, or it shows empty while the toast stays up local strip = e.frame:FindFirstChild("Time") :: Frame? if strip then strip.Size = UDim2.fromScale(1, strip.Size.Y.Scale) TweenService:Create(strip, TweenInfo.new(duration, Enum.EasingStyle.Linear), { Size = UDim2.fromScale(0, strip.Size.Y.Scale) }):Play() end task.delay(duration, function() if e.token == my then remove(e) end end) end export type Opts = { kind: string?, icon: string?, duration: number? } function Toasts.show(text: string, opts: Opts?) local o = opts or {} local duration = o.duration or (if #text > LONG_CHARS then LONG else SHORT) -- dedupe: same text already up -> pulse and restart its timer for _, e in stack do if e.text == text then local s = e.frame:FindFirstChildOfClass("UIScale") or Instance.new("UIScale", e.frame) s.Scale = 1.12 TweenService:Create(s, TweenInfo.new(0.25, Enum.EasingStyle.Back), { Scale = 1 }):Play() arm(e, duration) return end end if #stack >= MAX_STACK then remove(stack[1]) -- oldest drops end local f = template:Clone() f.AnchorPoint = Vector2.new(0.5, 0) local label = f:FindFirstChild("Text", true) :: TextLabel? if label then label.Text = text end f:SetAttribute("Kind", o.kind or "info") -- the template's own script colours the strip by kind f.Parent = container local e: Entry = { frame = f, text = text, token = 0 } table.insert(stack, e) relayout() local s = Instance.new("UIScale") s.Scale = 0.6 s.Parent = f TweenService:Create(s, TweenInfo.new(0.3, Enum.EasingStyle.Back), { Scale = 1 }):Play() arm(e, duration) end -- Burst merging: call this instead of show() for events that fire many times a second. -- At most one toast per `window` seconds, carrying the running total. local merged: { [string]: { total: number, last: number } } = {} function Toasts.merged(key: string, amount: number, fmt: (number) -> string, window: number) local m = merged[key] or { total = 0, last = 0 } merged[key] = m m.total += amount if os.clock() - m.last >= window then m.last = os.clock() Toasts.show(fmt(m.total), { duration = LONG }) m.total = 0 end end return Toasts