diff --git a/code/__DEFINES/layers.dm b/code/__DEFINES/layers.dm
index e4e490a41dc04c..11141d7e02fc8e 100644
--- a/code/__DEFINES/layers.dm
+++ b/code/__DEFINES/layers.dm
@@ -148,6 +148,9 @@
///Popup Chat Messages
#define RUNECHAT_PLANE 250
+/// Plane for balloon text (text that fades up)
+#define BALLOON_CHAT_PLANE 251
+
///Debug Atmos Overlays
#define ATMOS_GROUP_PLANE 450
diff --git a/code/__DEFINES/misc.dm b/code/__DEFINES/misc.dm
index 320ee46a68aa28..a7a08510cc7228 100644
--- a/code/__DEFINES/misc.dm
+++ b/code/__DEFINES/misc.dm
@@ -454,9 +454,6 @@ GLOBAL_LIST_INIT(pda_styles, sortList(list(MONO, VT, ORBITRON, SHARE)))
#define EGG_LAYING_MESSAGES list("lays an egg.","squats down and croons.","begins making a huge racket.","begins clucking raucously.")
-/// Prepares a text to be used for maptext. Use this so it doesn't look hideous.
-#define MAPTEXT(text) {"[##text]"}
-
//Filters
#define AMBIENT_OCCLUSION filter(type="drop_shadow", x=0, y=-2, size=4, color="#04080FAA")
#define GAUSSIAN_BLUR(filter_size) filter(type="blur", size=filter_size)
diff --git a/code/__DEFINES/text.dm b/code/__DEFINES/text.dm
new file mode 100644
index 00000000000000..58c24747e017af
--- /dev/null
+++ b/code/__DEFINES/text.dm
@@ -0,0 +1,5 @@
+/// Prepares a text to be used for maptext. Use this so it doesn't look hideous.
+#define MAPTEXT(text) {"[##text]"}
+
+/// Macro from Lummox used to get height from a MeasureText proc
+#define WXH_TO_HEIGHT(x) text2num(copytext(x, findtextEx(x, "x") + 1))
diff --git a/code/__HELPERS/game.dm b/code/__HELPERS/game.dm
index 600164cb3ce183..9b83f6e6d8c7f5 100644
--- a/code/__HELPERS/game.dm
+++ b/code/__HELPERS/game.dm
@@ -343,6 +343,10 @@
O.screen_loc = screen_loc
return O
+/// Removes an image from a client's `.images`. Useful as a callback.
+/proc/remove_image_from_client(image/image, client/remove_from)
+ remove_from?.images -= image
+
/proc/remove_images_from_clients(image/I, list/show_to)
for(var/client/C in show_to)
C.images -= I
diff --git a/code/datums/chatmessage.dm b/code/datums/chatmessage.dm
index 7d1f5114561c70..78152337492ca0 100644
--- a/code/datums/chatmessage.dm
+++ b/code/datums/chatmessage.dm
@@ -16,8 +16,6 @@
#define CHAT_MESSAGE_MAX_LENGTH 110
/// The dimensions of the chat message icons
#define CHAT_MESSAGE_ICON_SIZE 9
-/// Macro from Lummox used to get height from a MeasureText proc
-#define WXH_TO_HEIGHT(x) text2num(copytext(x, findtextEx(x, "x") + 1))
///Base layer of chat elements
#define CHAT_LAYER 1
@@ -317,4 +315,3 @@
#undef CHAT_LAYER_Z_STEP
#undef CHAT_LAYER_MAX_Z
#undef CHAT_MESSAGE_ICON_SIZE
-#undef WXH_TO_HEIGHT
diff --git a/code/modules/balloon_alert/balloon_alert.dm b/code/modules/balloon_alert/balloon_alert.dm
new file mode 100644
index 00000000000000..c068d08d56ce70
--- /dev/null
+++ b/code/modules/balloon_alert/balloon_alert.dm
@@ -0,0 +1,54 @@
+#define BALLOON_TEXT_WIDTH 200
+#define BALLOON_TEXT_SPAWN_TIME (0.2 SECONDS)
+#define BALLOON_TEXT_FADE_TIME (0.1 SECONDS)
+#define BALLOON_TEXT_FULLY_VISIBLE_TIME (0.7 SECONDS)
+#define BALLOON_TEXT_TOTAL_LIFETIME (BALLOON_TEXT_SPAWN_TIME + BALLOON_TEXT_FULLY_VISIBLE_TIME + BALLOON_TEXT_FADE_TIME)
+
+/// Creates text that will float from the atom upwards to the viewer.
+/atom/proc/balloon_alert(mob/viewer, text)
+ var/client/viewer_client = viewer.client
+ if (isnull(viewer_client))
+ return
+
+ var/bound_width = world.icon_size
+ if (ismovable(src))
+ var/atom/movable/movable_source = src
+ bound_width = movable_source.bound_width
+
+ var/image/balloon_alert = image(loc = loc, layer = ABOVE_MOB_LAYER)
+ balloon_alert.plane = BALLOON_CHAT_PLANE
+ balloon_alert.alpha = 0
+ balloon_alert.maptext = MAPTEXT("[text]")
+ balloon_alert.maptext_x = (BALLOON_TEXT_WIDTH - bound_width) * -0.5
+ balloon_alert.maptext_height = WXH_TO_HEIGHT(viewer_client?.MeasureText(text, null, BALLOON_TEXT_WIDTH))
+ balloon_alert.maptext_width = BALLOON_TEXT_WIDTH
+
+ viewer_client?.images += balloon_alert
+
+ animate(
+ balloon_alert,
+ pixel_y = world.icon_size * 1.2,
+ time = BALLOON_TEXT_TOTAL_LIFETIME,
+ easing = SINE_EASING | EASE_OUT,
+ )
+
+ animate(
+ alpha = 255,
+ time = BALLOON_TEXT_SPAWN_TIME,
+ easing = CUBIC_EASING | EASE_OUT,
+ flags = ANIMATION_PARALLEL,
+ )
+
+ animate(
+ alpha = 0,
+ time = BALLOON_TEXT_FULLY_VISIBLE_TIME,
+ easing = CUBIC_EASING | EASE_IN,
+ )
+
+ addtimer(CALLBACK(GLOBAL_PROC, .proc/remove_image_from_client, balloon_alert, viewer_client), BALLOON_TEXT_TOTAL_LIFETIME)
+
+#undef BALLOON_TEXT_FADE_TIME
+#undef BALLOON_TEXT_FULLY_VISIBLE_TIME
+#undef BALLOON_TEXT_SPAWN_TIME
+#undef BALLOON_TEXT_TOTAL_LIFETIME
+#undef BALLOON_TEXT_WIDTH
diff --git a/code/modules/reagents/reagent_containers.dm b/code/modules/reagents/reagent_containers.dm
index 4a633ee408dbe3..d269c59b11ac89 100644
--- a/code/modules/reagents/reagent_containers.dm
+++ b/code/modules/reagents/reagent_containers.dm
@@ -56,7 +56,7 @@
amount_per_transfer_from_this = possible_transfer_amounts[i+1]
else
amount_per_transfer_from_this = possible_transfer_amounts[1]
- to_chat(user, "[src]'s transfer amount is now [amount_per_transfer_from_this] units.")
+ balloon_alert(user, "Transferring [amount_per_transfer_from_this]u")
return
/obj/item/reagent_containers/pre_attack_secondary(atom/target, mob/living/user, params)
diff --git a/tgstation.dme b/tgstation.dme
index 4432a414ff6f2c..ba9c16bf75697f 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -135,6 +135,7 @@
#include "code\__DEFINES\strippable.dm"
#include "code\__DEFINES\subsystems.dm"
#include "code\__DEFINES\supermatter.dm"
+#include "code\__DEFINES\text.dm"
#include "code\__DEFINES\tgs.config.dm"
#include "code\__DEFINES\tgs.dm"
#include "code\__DEFINES\tgui.dm"
@@ -1883,6 +1884,7 @@
#include "code\modules\awaymissions\mission_code\stationCollision.dm"
#include "code\modules\awaymissions\mission_code\undergroundoutpost45.dm"
#include "code\modules\awaymissions\mission_code\wildwest.dm"
+#include "code\modules\balloon_alert\balloon_alert.dm"
#include "code\modules\buildmode\bm_mode.dm"
#include "code\modules\buildmode\buildmode.dm"
#include "code\modules\buildmode\buttons.dm"