Rich Subtitles | Customizable animated subtitles

Hello everyone, this is my first resource, I hope you all can let me know if I should be adding any additional information.

I’ve seen a lot of subtitle modules but none of them have the cool animated effects or features I wanted, so I created this for my project “Frontier Stadium” and want to share it with the community.

Features:

+Uses custom rich text for animation
+Supports pauses, cancelling, skipping, and interrupts
+Supports character speaking sounds (Like undertale or animal crossing)
+Text appears gradually after the last character (Caterpillar style)
+Fully customizable, you can add your own rich text, style, settings, and more.
+Scales well on mobile

Documentation

init(holder,soundStorage)
	holder should be a ui element like a frame, this determines where subtitles are generated
	soundStorage should be a folder with other folders named voiceNames with sound objects inside them, they will be randomly chosen if a voice is used when displaying subtitles. (Optional)

displaySubtitle(text,voiceName)
	text is the string you want to display
	voiceName should be the name of a folder inside soundStorage (Optional)

skip()
	Instantly shows the completed state of the current displayed text.

cancel()
	Cancels the current displayed text.

Available custom rich text

<b>Bold</b>
<s>Small</s>
<c#FF0000>colored text</c> (Hex color code)
<g>Glitching</g>
<h>Shaking</h>
<j>Jumping</j>
<o>Orbit</o>
<r>Rotating</r>
<pX> This is a pause (Replace X with a number)

Extra settings are available inside the main module.

The default appearance can be changed in the “Char” module.

Potential issues

Uses a manual size check for characters, this is because using TextService:GetTextSize() sometimes gives inconsistent results.

The subtitles works with mobile devices, but it is recommended to keep text short or else they may go off screen.

Video and Place

Uncopylocked place:

Model

Please let me know if you found any bugs or have feedback, thank you!

19 Likes

Very cool system! Could you upload it as a module, please?

1 Like

Hello, thanks for trying it out! I’ve updated the original post to include the model.

I love this! I will be using this in a future project.

But one thing you should add is only the animations (like glitch or jumping) cause what if I don’t want it to be animated (caterpillar style) but what the jumping animation. There should be ways to connect and disconnect these animations.

This would make this module the best it can be!

1 Like

Maybe you can a function, that just loads the animations for a text.

Example

module:LoadText(textlabel, "hello there")

This is fantastic. Can we add our own custom rich text to this easily?

1 Like

Hello everyone, thank you for your feedback and questions.

In the case you don’t want it to have the caterpillar style appearance animation for the text, you can skip it as soon as it’s created! You can also modify the tween info and properties in the settings to your liking to change the style and speed.

Yes! Here’s a 3 step process to add a custom effect:

  1. See line 130, add yourRichTextWord = "functionName" inside effectNameMap
local effectNameMap = {
	h = "shake",
	...
  1. Go to line 138 to add an effect function inside the effectHandlers, the label object will be passed in. functionName = function(label)
local effectHandlers = {
	shake = function(label)
		label.AnchorPoint = Vector2.new(math.random(40, 60) / 100, math.random(40, 60) / 100)
		label.Rotation = math.random(-50, 50) / 10
	end,
	...
  1. Update the documentation at the top of the script (I forget to use new ones somestimes :sob:)
2 Likes

Quick update to the module:

  • Improved how to implement custom rich text effects, effectNameMap was moved outside. (I updated my previous post with updated instructions, it’s even easier now)
  • Added MANUAL_DISAPPEAR to settings (off by default), use module.disappearAnimation() to make the text disappear.

does disappearAnimation also disconnect all connected events? if not is there a way to disconnect connections?

Amazing! Will definitely try out!

Hey everyone!

I’ve added a cool new feature that lets you continue subtitles, even after a short pause! :blush:

Example GIF

It’s super easy to use: In displaySubtitle(text, voiceName, append), set the third parameter to true for the follow-up call, and provide the full updated text (it must start exactly like the previous one).

Here’s a quick example:

--Play button
script.Parent.MouseButton1Down:Connect(function()
	local voiceName = nil
	task.wait(1)
	subtitlesHandler.displaySubtitle("well.",voiceName)  -- first part
	task.wait(1.5)
	subtitlesHandler.displaySubtitle("well. youve reached the end.",voiceName, true)  -- full text with old one
	task.wait(2)
	subtitlesHandler.displaySubtitle("congrats",voiceName)
end)

Grab the full package here: https://create.roblox.com/store/asset/85388514154973 :tada:

1 Like

also is there a way just to animate a chosen text label?

I have returned.

so, I was trying to get this script to work with multiple text labels, just for my game to constantly be crashing.

if anyone wants to try and fix it be my guess:

--Licensed under the MIT license
--Made by Powerbow47, refactored using gemini

--[[

=== [Methods] ===

init(holder,soundStorage)
	holder should be a ui element like a frame, this determines where subtitles are generated
	soundStorage should be a folder with other folders named voiceNames with sound objects inside them, they will be randomly chosen if a voice is used when displaying subtitles

displaySubtitle(text,voiceName)
	text is the text you want to display
	voiceName should be the name of a folder inside soundStorage

getCurrentSubtitleTask()

=== [Custom rich text] ===

<b>Bold</b>
<s>Small</s>
<c#FF0000>colored text</c> (Hex color code)
<g>Glitching</g>
<h>Shaking</h>
<j>Jumping</j>
<o>Orbit</o>
<r>Rotating</r>
<pX> (Replace X with a number)

]]

local module = {}

--// Services
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")
local TextService = game:GetService("TextService")
local LocalizationService = game:GetService("LocalizationService")

--// UI Elements & Assets
local holder:Frame
local SoundsStorage:Folder
local characterTemplate = require(script.Char).get()

--// Modules
local SoundManagerModule = require(script.SoundCache)
local soundManager

--// Configuration
local CONFIG = {
	-- Timing
	PIXELS_PER_SECOND = 200,
	READ_CHARS_PER_SECOND = 15,
	MIN_DISPLAY_DURATION = 2.5,
	MAX_DISPLAY_DURATION = 7.0,
	CONSTANT = 1.38,
	BASE_SPEED = 600,
	MANUAL_DISAPPEAR = true,

	-- Animation
	APPEAR_INFO = TweenInfo.new(0.1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
	APPEAR_BACK_INFO = TweenInfo.new(0.45, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
	DISAPPEAR_INFO = TweenInfo.new(0.75, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
	STROKE_APPEAR_INFO = TweenInfo.new(0.2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out),
	JUMP_EFFECT = {
		COOLDOWN = 0.2,
		LERP_ALPHA = 0.05,
		ROTATION_DECAY = 0.95,
	},

	-- Sound
	SILENT_LETTERS = {
		['"'] = true, ["'"] = true, ["~"] = true, [","] = true, ["."] = true,
		["_"] = true, ["?"] = true, ["!"] = true, ["@"] = true, ["["] = true,
		["]"] = true, ["("] = true, [")"] = true, ["^"] = true, ["+"] = true,
		["-"] = true, ["*"] = true,
	},

	-- Effects
	GLITCHED_CHARACTERS = {"░", "▒", "▓", "█", "▀", "▄", "▙", "▚", "▛", "▜", "▞", "▟", "☐"},
}
local JUMP_CONFIG = CONFIG.JUMP_EFFECT
local textSizes = {
	[1] = { --size = 20
		[" "] = 5,["!"] = 4,['"'] = 8,["#"] = 12,["$"] = 10,["%"] = 9,["&"] = 12,["'"] = 5,["("] = 5,[")"] = 5,["*"] = 9,
		["+"] = 10,[","] = 4,["-"] = 6,["."] = 4,["/"] = 9,["0"] = 11,["1"] = 6,["2"] = 11,["3"] = 11,["4"] = 11,["5"] = 11,
		["6"] = 11,["7"] = 11,["8"] = 11,["9"] = 11,["<"] = 11,["="] = 9,[">"] = 11,["?"] = 10,["@"] = 13,["A"] = 12,["B"] = 11,
		["C"] = 11,["D"] = 11,["E"] = 10,["F"] = 10,["G"] = 11,["H"] = 11,["I"] = 5,["J"] = 10,["K"] = 11,["L"] = 10,["M"] = 12,
		["N"] = 11,["O"] = 11,["P"] = 11,["Q"] = 11,["R"] = 11,["S"] = 10,["T"] = 10,["U"] = 11,["V"] = 11,["W"] = 12,["X"] = 10,
		["Y"] = 12,["Z"] = 10,["["] = 5,["]"] = 5,["^"] = 9,["_"] = 11,["a"] = 9,["b"] = 10,["c"] = 9,["d"] = 10,["e"] = 9,
		["f"] = 5,["g"] = 10,["h"] = 9,["i"] = 5,["j"] = 5,["k"] = 10,["l"] = 5,["m"] = 15,["n"] = 9,["o"] = 9,["p"] = 10,
		["q"] = 10,["r"] = 7,["s"] = 8,["t"] = 6,["u"] = 9,["v"] = 10,["w"] = 12,["x"] = 10,["y"] = 10,["z"] = 8,["{"] = 6,
		["|"] = 4,["}"] = 6,["~"] = 11
	},
	[2] = { --size = 35
		[" "] = 8,["!"] = 7,['"'] = 14,["#"] = 21,["$"] = 17,["%"] = 16,["&"] = 20,["'"] = 8,["("] = 9,[")"] = 9,["*"] = 15,
		["+"] = 17,[","] = 7,["-"] = 10,["."] = 7,["/"] = 16,["0"] = 19,["1"] = 10,["2"] = 19,["3"] = 18,["4"] = 19,["5"] = 18,
		["6"] = 18,["7"] = 18,["8"] = 18,["9"] = 18,["<"] = 19,["="] = 16,[">"] = 19,["?"] = 17,["@"] = 23,["A"] = 20,["B"] = 18,
		["C"] = 18,["D"] = 18,["E"] = 17,["F"] = 17,["G"] = 19,["H"] = 18,["I"] = 9,["J"] = 17,["K"] = 18,["L"] = 17,["M"] = 20,
		["N"] = 18,["O"] = 19,["P"] = 18,["Q"] = 19,["R"] = 18,["S"] = 17,["T"] = 17,["U"] = 18,["V"] = 18,["W"] = 21,["X"] = 17,
		["Y"] = 21,["Z"] = 17,["["] = 9,["]"] = 9,["^"] = 16,["_"] = 18,["a"] = 16,["b"] = 17,["c"] = 15,["d"] = 17,["e"] = 16,
		["f"] = 9,["g"] = 17,["h"] = 16,["i"] = 8,["j"] = 8,["k"] = 17,["l"] = 8,["m"] = 26,["n"] = 16,["o"] = 16,["p"] = 17,
		["q"] = 17,["r"] = 12,["s"] = 14,["t"] = 10,["u"] = 16,["v"] = 17,["w"] = 21,["x"] = 17,["y"] = 17,["z"] = 14,["{"] = 11,
		["|"] = 7,["}"] = 11,["~"] = 19
	},
	[3] = { --size = 50
		[" "] = 12,["!"] = 9,['"'] = 20,["#"] = 30,["$"] = 24,["%"] = 22,["&"] = 28,["'"] = 12,["("] = 13,[")"] = 13,["*"] = 21,
		["+"] = 23,[","] = 9,["-"] = 14,["."] = 9,["/"] = 22,["0"] = 26,["1"] = 14,["2"] = 27,["3"] = 26,["4"] = 26,["5"] = 26,
		["6"] = 26,["7"] = 26,["8"] = 26,["9"] = 26,["<"] = 27,["="] = 23,[">"] = 27,["?"] = 24,["@"] = 32,["A"] = 28,["B"] = 26,
		["C"] = 26,["D"] = 26,["E"] = 24,["F"] = 24,["G"] = 26,["H"] = 26,["I"] = 13,["J"] = 24,["K"] = 26,["L"] = 24,["M"] = 28,
		["N"] = 26,["O"] = 26,["P"] = 26,["Q"] = 26,["R"] = 26,["S"] = 24,["T"] = 24,["U"] = 26,["V"] = 26,["W"] = 29,["X"] = 24,
		["Y"] = 29,["Z"] = 24,["["] = 13,["]"] = 13,["^"] = 22,["_"] = 26,["a"] = 22,["b"] = 23,["c"] = 21,["d"] = 23,["e"] = 22,
		["f"] = 13,["g"] = 23,["h"] = 22,["i"] = 11,["j"] = 11,["k"] = 24,["l"] = 11,["m"] = 36,["n"] = 22,["o"] = 22,["p"] = 23,
		["q"] = 23,["r"] = 16,["s"] = 20,["t"] = 14,["u"] = 22,["v"] = 23,["w"] = 30,["x"] = 24,["y"] = 24,["z"] = 20,["{"] = 15,
		["|"] = 9,["}"] = 15,["~"] = 27
	},
}
--// State Variables
local activeEffects = {}
local activeLabelEffects = {}
local activeEffectConnection = nil
local currentSubtitleTask = nil
local isSubtitleActive = false

local ActiveLabels = {
	
}

--================------------------------------------------------------------------
--=                                    EFFECTS                                     =
--================------------------------------------------------------------------

-- A dictionary of functions to handle visual effects per frame.
local effectNameMap = {
	h = "shake",
	g = "glitch",
	j = "jump",
	r = "rotate",
	o = "orbit"
}

local effectHandlers = {
	shake = function(label)
		label.AnchorPoint = Vector2.new(math.random(30, 40) / 100, math.random(30, 40) / 100)
		label.Rotation = math.random(-50, 50) / 10
	end,
	glitch = function(label)
		if math.random(1, 10) == 1 then
			label.Text = label:GetAttribute("OriginalChar")
		else
			label.Text = CONFIG.GLITCHED_CHARACTERS[math.random(#CONFIG.GLITCHED_CHARACTERS)]
		end
	end,
	jump = function(label)
		-- Lazily create state attributes on the label itself
		local lastJumpTime = label:GetAttribute("LastJumpTime") or 0
		if (os.clock() - lastJumpTime) > JUMP_CONFIG.COOLDOWN then
			label:SetAttribute("LastJumpTime", os.clock())
			if math.random(0, 1) == 0 then
				label.AnchorPoint = Vector2.new(math.random(40, 60) / 100, math.random(40, 60) / 100)
				label.Rotation = math.random(-50, 50) / 10
			end
		else
			label.AnchorPoint = label.AnchorPoint:Lerp(Vector2.new(0.5, 0.5), JUMP_CONFIG.LERP_ALPHA)
			label.Rotation *= JUMP_CONFIG.ROTATION_DECAY
		end
	end,
	rotate = function(label)
		local startTime = label:GetAttribute("StartTime")
		local index = label:GetAttribute("EffectIndex")
		local timeElapsed = (os.clock() - startTime + index / 80) * 8
		label.Rotation = math.sin(timeElapsed) * 10
	end,
	orbit = function(label)
		local startTime = label:GetAttribute("StartTime")
		local index = label:GetAttribute("EffectIndex")
		local timeElapsed = (os.clock() - startTime + index / 80) * 8
		label.AnchorPoint = Vector2.new(.5+(math.sin(timeElapsed) + 1) / 12, .5+(math.cos(timeElapsed) + 1) / 12)
	end,
}

function module.updateEffects()
	-- Only disconnect if no subtitle is active AND there are no effects left.
	-- This prevents the connection from destroying itself before effects are added.
	--[[if #activeEffects == 0 and not isSubtitleActive then
		if activeEffectConnection then
			activeEffectConnection:Disconnect()
			activeEffectConnection = nil
		end
		return
	end]]

	for _, Slot in ipairs(ActiveLabels) do
		local Characters = Slot.Characters
		
		for _, Character in ipairs(Characters) do
			local effect = Character:GetAttribute("Effect")
			
			if effect then
				if effectHandlers[effect] then
					effectHandlers[effect](Character)
				end
			end
		end
	end
end

--================------------------------------------------------------------------
--=                                    PARSING                                     =
--================------------------------------------------------------------------

-- Parses the entire markup string at once and returns a list of character objects
function parseMarkup(text: string)
	local characters = {}
	local totalWidth = 0

	local state = {
		-- We now use a size index (1=small, 2=normal, 3=big) for the lookup table
		sizeIndex = 2, -- Start at normal size
		color = characterTemplate.TextColor3,
		effects = {},
	}

	local pattern = "<(.-)>"
	local lastIndex = 1

	for tagContent in text:gmatch(pattern) do
		local tagStart = text:find("<" .. tagContent .. ">", lastIndex, true)

		-- 1. Process plain text before the tag
		local plainText = text:sub(lastIndex, tagStart - 1)
		for char in plainText:gmatch(".") do
			local charWidth = (textSizes[state.sizeIndex] and textSizes[state.sizeIndex][char]) or 15
			local charHeight = (state.sizeIndex == 1 and 20 or state.sizeIndex == 3 and 50 or 35)

			local charEffects = {}
			for k, v in pairs(state.effects) do if v then charEffects[k] = v end end

			table.insert(characters, {
				Text = char,
				Width = charWidth,
				Height = charHeight,
				Color = state.color,
				Effects = charEffects,
			})
			totalWidth += charWidth
		end

		-- 2. Process the tag itself
		local tagType = tagContent:sub(1, 1)
		local isClosing = (tagType == "/")
		if isClosing then tagType = tagContent:sub(2, 2) end

		-- UPDATE THE SIZE INDEX
		if tagType == "b" then
			state.sizeIndex = isClosing and 2 or 3 -- big
		elseif tagType == "s" then
			state.sizeIndex = isClosing and 2 or 1 -- small
		elseif tagType == "c" then
			if isClosing then
				state.color = characterTemplate.TextColor3
			else
				state.color = Color3.fromHex(tagContent:sub(2))
			end
		elseif effectNameMap[tagType] then
			state.effects[effectNameMap[tagType]] = not isClosing
		end

		lastIndex = tagStart + #tagContent + 2
	end

	-- 3. Process any remaining plain text after the last tag
	local remainingText = text:sub(lastIndex)
	for char in remainingText:gmatch(".") do
		local charWidth = (textSizes[state.sizeIndex] and textSizes[state.sizeIndex][char]) or 15
		local charHeight = (state.sizeIndex == 1 and 20 or state.sizeIndex == 3 and 50 or 35)

		local charEffects = {}
		for k, v in pairs(state.effects) do if v then charEffects[k] = v end end

		table.insert(characters, {
			Text = char,
			Width = charWidth,
			Height = charHeight,
			Color = state.color,
			Effects = charEffects,
		})
		totalWidth += charWidth
	end

	return characters, totalWidth
end

--================------------------------------------------------------------------
--=                                    MAIN LOGIC                                  =
--================------------------------------------------------------------------

function playRandomSound()
	if soundManager then
		soundManager:PlaySound()
	end
end

function module.disappearAnimation(HolderOverright)
	local holder = HolderOverright or holder
	
	for _, v in ipairs(holder:GetChildren()) do
		if v:IsA("TextLabel") then
			TweenService:Create(v, CONFIG.DISAPPEAR_INFO, {TextTransparency = 1}):Play()
			TweenService:Create(v.UIStroke, CONFIG.APPEAR_INFO, {Transparency = 1}):Play()
		end
	end
	task.wait(CONFIG.DISAPPEAR_INFO.Time)
	table.clear(activeEffects)
	holder:ClearAllChildren()
end

-- AnimateText
function module.AnimateText(TextLabel: TextLabel)
	if TextLabel == nil then
		warn("TextLabel missing")
		return
	end
	
	task.spawn(function()
		local ListToRegister = SetUpTextLabel(TextLabel)
		
		local Number = #ActiveLabels + 1
		
		ActiveLabels[Number] = ListToRegister
		
		local Connection 
		
		Connection = TextLabel.Destroying:Connect(function()
			ActiveLabels[Number] = nil
			Connection:Disconnect()
		end)
	end)
	
end

function SetUpTextLabel(TextLabel)
	local text = TextLabel.Text
	local holder = TextLabel
	TextLabel.Text = ""
	
	local readChars = 0
	local segments = {}
	local pattern = "<p([%d%.]+)>"
	local lastEnd = 1
	
	local ListToRegister = {
		Label = holder,
		Characters = {},
	}
	
	for num, newEnd in text:gmatch(pattern .. "()") do
		local segmentText = text:sub(lastEnd, newEnd - #num - 4)
		table.insert(segments, {Text = segmentText, Pause = tonumber(num)})
		lastEnd = newEnd
	end
	if lastEnd <= #text then
		table.insert(segments, {Text = text:sub(lastEnd), Pause = 0})
	end

	local basePPS = CONFIG.BASE_SPEED
	local pps = CONFIG.PIXELS_PER_SECOND
	local dynamicConstant = pps > basePPS and CONFIG.CONSTANT * (pps / basePPS) or (CONFIG.CONSTANT * ((basePPS - pps) / basePPS))

	-- 3. Animate each segment
	local previousLabel = nil
	local allLabels = {}
	local Effects = {}

	for _, segment in ipairs(segments) do

		local characters, totalWidth = parseMarkup(segment.Text)
		readChars += #characters

		local fullTextWidth = 0
		for _, lbl in ipairs(allLabels) do fullTextWidth += lbl.Size.X.Offset end
		fullTextWidth += totalWidth

		local lastLabelStateBeforeShift = nil
		if #allLabels > 0 then
			local lastLabel = allLabels[#allLabels]
			lastLabelStateBeforeShift = {
				Position = lastLabel.Position,
				Size = lastLabel.Size
			}
		end

		local currentX = (holder.AbsoluteSize.X - fullTextWidth) / 2
		for _, lbl in ipairs(allLabels) do
			local newPos = UDim2.fromScale((currentX + lbl.Size.X.Offset / 2) / holder.AbsoluteSize.X, 0.5)
			local distanceToMove = math.abs(newPos.X.Scale - lbl.Position.X.Scale) * holder.AbsoluteSize.X
			local shiftDuration = distanceToMove / CONFIG.PIXELS_PER_SECOND * dynamicConstant
			TweenService:Create(lbl, TweenInfo.new(shiftDuration, Enum.EasingStyle.Linear), {Position = newPos}):Play()
			currentX += lbl.Size.X.Offset
		end
		
		for i, charData in ipairs(characters) do
			local label = TextLabel:Clone()
			label.Text = charData.Text
			label.TextColor3 = charData.Color
			label.Size = UDim2.fromOffset(charData.Width, charData.Height)
			label.Visible = true
			label.Name = "Char_"..(#allLabels + 1)
			label.Parent = holder
			table.insert(allLabels, label)
			label:SetAttribute("OriginalChar", charData.Text)
			
			if charData.Effects then
				label:SetAttribute("StartTime", os.clock())
				label:SetAttribute("EffectIndex", #activeEffects + 1)
				for effectName, _ in charData.Effects do
					label:SetAttribute("Effect", effectName) -- Use the instance as the key
				end
			end

			local startX
			if i == 1 and lastLabelStateBeforeShift then
				-- This is the first char of a new segment.
				-- Use the CAPTURED state from before the old text started moving.
				-- This makes the new char emerge from where the old text WAS, fixing the gap.
				startX = lastLabelStateBeforeShift.Position.X.Scale * holder.AbsoluteSize.X + lastLabelStateBeforeShift.Size.X.Offset / 2 + charData.Width / 2
			else
				-- This is the standard "caterpillar" case for all other characters.
				-- We use the live position of the previous character.
				startX = previousLabel and (previousLabel.Position.X.Scale * holder.AbsoluteSize.X + previousLabel.Size.X.Offset / 2 + charData.Width / 2) or holder.AbsoluteSize.X / 2
			end

			local finalX = currentX + charData.Width / 2
			label.Position = UDim2.fromScale(startX / holder.AbsoluteSize.X, 0.5)
			label.Size = UDim2.fromOffset(charData.Width, charData.Height / 4)
			label.Rotation = math.random(15, 35) * (math.random(0, 1) == 1 and 1 or -1)
			local travelDuration = math.abs(finalX - startX) / CONFIG.PIXELS_PER_SECOND * dynamicConstant
			TweenService:Create(label, TweenInfo.new(travelDuration, Enum.EasingStyle.Linear), {Position = UDim2.fromScale(finalX / holder.AbsoluteSize.X, 0.5)}):Play()
			TweenService:Create(label, CONFIG.APPEAR_INFO, {TextTransparency = 0}):Play()
			TweenService:Create(label.UIStroke, CONFIG.STROKE_APPEAR_INFO, {Transparency = 0}):Play()
			TweenService:Create(label, CONFIG.APPEAR_BACK_INFO, {Size = UDim2.fromOffset(charData.Width, charData.Height), Rotation = 0}):Play()
			if not CONFIG.SILENT_LETTERS[charData.Text] then playRandomSound() end
			task.wait((charData.Width / CONFIG.PIXELS_PER_SECOND) / 2)
			previousLabel = label
			currentX += charData.Width
		end

		task.wait(segment.Pause)
	end
	
	ListToRegister.Characters = allLabels
	
	return ListToRegister
end

-- This is the main animation function, designed to be run in its own coroutine.
function animateSubtitle(text: string, cancelToken: {cancelled: boolean, skipped: boolean}, TextLabelOverright: TextLabel)
	-- 1. Teardown & Setup
	local holder = TextLabelOverright or holder
	
	if TextLabelOverright then
		text = TextLabelOverright.Text
		TextLabelOverright.Text = ""
	end
	
	for _, label in ipairs(holder:GetChildren()) do
		if activeLabelEffects[label] then
			activeLabelEffects[label] = nil -- Dereference to allow garbage collection
		end
	end
	--table.clear(activeEffects)
	holder:ClearAllChildren()
	local readChars = 0

	-- 2. Split by pause tags to create segments for animation
	SetUpTextLabel(holder, cancelToken)
end

function module.init(gotHolder: GuiBase,gotSoundsStorage: Folder?)
	holder = gotHolder
	SoundsStorage = gotSoundsStorage
	if SoundsStorage then
		local soundsFolder = Instance.new("Folder")
		soundsFolder.Name = "Sounds"
		soundsFolder.Parent = script
		
		soundManager = SoundManagerModule.new(soundsFolder,SoundsStorage)
	end
end

function module.displaySubtitle(text: string,voiceName: string?)
	if voiceName then
		if soundManager then
			soundManager:SetVoice(voiceName)
		else
			warn("No sound storage given when module initialized.")
		end
	else
		soundManager:SetVoice()
	end
	-- Cancel the previous subtitle task if it exists
	if currentSubtitleTask then
		currentSubtitleTask.cancelToken.cancelled = true
		task.cancel(currentSubtitleTask.thread)
	end

	isSubtitleActive = true -- Set flag: a subtitle process has begun

	-- Start the effect handler if it's not already running

	-- Create a new task for the new subtitle
	local cancelToken = { cancelled = false, skipped = false }
	local thread = task.spawn(function()
		-- Run the main animation
		animateSubtitle(text, cancelToken)

		-- IMPORTANT: Once the animation coroutine is completely finished,
		-- mark it as inactive. This tells updateEffects it can now safely clean up.
		isSubtitleActive = false 
	end)

	currentSubtitleTask = {
		thread = thread,
		cancelToken = cancelToken,
	}
end

function module.getCurrentSubtitleTask():{thread:thread,cancelToken:{cancelled:boolean,skipped:boolean}}
	return currentSubtitleTask
end

return module

:white_check_mark: Update 1.1

  • Added cancelling module.cancel
  • Added a skip function to make skipping easiermodule.skip
  • Added a function for getting text sizes in a different font module.getFontWidths
  • Centralized cleanUp function
  • Explained what <pX> does (It’s pause, I forgot to do this earlier)

Instructions on adding your own font:

  1. Go into the “Char” module inside SubtitlesHandler and change the font enum on line 2.
local font = Enum.Font.Highway
  1. Call module.getFontWidths, pass in the font and size you want, here’s an example:
module.getFontWidths(Font.fromEnum(Enum.Font.Highway),30)
  1. You should get a table in the output. Paste that table inside of textSizes and replace one of the tables, repeat step 2 and 3 for three times for big, normal, and small.
    Note: Make sure the indexes stay 1, 2, 3.
local textSizes = {
	[1] = { --size = 20
		[" "] = 5,["!"] = 4,['"'] = 8,["#"] = 12,["$"] = 10,["%"] = 9,["&"] = 12,["'"] = 5,["("] = 5,[")"] = 5,["*"] = 9,
		["+"] = 10,[","] = 4,["-"] = 6,["."] = 4,["/"] = 9,["0"] = 11,["1"] = 6,["2"] = 11,["3"] = 11,["4"] = 11,["5"] = 11,
		["6"] = 11,["7"] = 11,["8"] = 11,["9"] = 11,["<"] = 11,["="] = 9,[">"] = 11,["?"] = 10,["@"] = 13,["A"] = 12,["B"] = 11,
		["C"] = 11,["D"] = 11,["E"] = 10,["F"] = 10,["G"] = 11,["H"] = 11,["I"] = 5,["J"] = 10,["K"] = 11,["L"] = 10,["M"] = 12,
		["N"] = 11,["O"] = 11,["P"] = 11,["Q"] = 11,["R"] = 11,["S"] = 10,["T"] = 10,["U"] = 11,["V"] = 11,["W"] = 12,["X"] = 10,
		["Y"] = 12,["Z"] = 10,["["] = 5,["]"] = 5,["^"] = 9,["_"] = 11,["a"] = 9,["b"] = 10,["c"] = 9,["d"] = 10,["e"] = 9,
		["f"] = 5,["g"] = 10,["h"] = 9,["i"] = 5,["j"] = 5,["k"] = 10,["l"] = 5,["m"] = 15,["n"] = 9,["o"] = 9,["p"] = 10,
		["q"] = 10,["r"] = 7,["s"] = 8,["t"] = 6,["u"] = 9,["v"] = 10,["w"] = 12,["x"] = 10,["y"] = 10,["z"] = 8,["{"] = 6,
		["|"] = 4,["}"] = 6,["~"] = 11
	},
	...

QnA

I would recommend trying to use the module in a different thread. This should allow you to have multiple lines!