The way I went about it was each time a character was written, it got assigned as text in the label as a new string, as opposed to adding to existing characters.
--[[=[
@voozy_v | 2025 Mar 10
]=]]
local Debris = game:GetService("Debris")
local function FormatRichText(text:string)
local segments = {}
local i = 1
local inTag = false
local currentTag = ""
local visibleText = ""
local tagStack = {}
while i <= #text do
local char = text:sub(i, i)
if char == "<" then
local endPos = text:find(">", i)
if endPos then
local tag = text:sub(i, endPos)
table.insert(segments, { text = tag, isTag = true })
if tag:sub(2, 2) ~= "/" then
table.insert(tagStack, tag)
else
table.remove(tagStack) -- Close last tag
end
i = endPos + 1
else
i += 1
end
else
table.insert(segments, { text = char, isTag = false, tags = table.clone(tagStack) })
i += 1
end
end
return segments
end
local function Construct(segments:{}, count:number)
local built = ""
local typed = 0
for _, seg in ipairs(segments) do
if seg.isTag then
built ..= seg.text
elseif typed < count then
local open = ""
local close = ""
for _, tag in ipairs(seg.tags or {}) do
open ..= tag
local tagName = tag:match("<(%w+)")
close = ("</%s>"):format(tagName) .. close
end
built ..= open .. seg.text .. close
typed += 1
end
end
return built
end
return function(label:TextLabel|TextBox, text:string, interval:number?)
local existingText = label.Text ~= "" and (label.Text .. " ") or ""
local segments = FormatRichText(text)
local visibleCount = 0
for _, seg in ipairs(segments) do
if not seg.isTag then
visibleCount += 1
end
end
for i = 1, visibleCount do
label.Text = existingText .. Construct(segments, i)
local sound = script.Parent.Sound:Clone()
sound.Parent = workspace
sound:Play()
Debris:AddItem(sound, 1)
task.wait(interval or 0.025)
end
end
I wrote this as part of my Label module, and this is the Write module in it with the logic. The solution is a bit hacky but it works.
This is the whole Label module if you want to use:
Label_.rbxm (17.2 KB)
There’s a disabled LocalScript named “UsageExample” in there, just enable it and put the example Dialog gui in StarterGui for it to work.
You can also choose between Wait mode and Input mode, allowing you to click to play the next text or just wait. Go to the Type module and change the delay_mode
to either one. It’s currently set to Input
, and the input_character
is MB1 so you have to click to continue the text.
Type.config = {
type_character_interval = 0.025,
delay_mode = "Input", -- "Wait",
input_character = Enum.UserInputType.MouseButton1 -- Enum.KeyCode.E
}