Spring is a great time to add a little personality to your community. In this #TipTuesday example, we’ll build a lightweight emoji spray that blooms across the discussion page when a member opens it.
The effect is decorative, reusable, and configurable. You can use flowers, leaves, stars, butterflies, confetti, or any other emoji that fits the moment.
What we’re building
We’ll create a custom Widget Builder widget that can:
- Display a configurable emoji.
- Control the number of emojis and the animation speed.
- Dissolve automatically after five seconds.
- Run on every discussion page using the layout, or only on a selected discussion path.
- Optionally target a signed-in member using a profile field, such as a birthday.
- Respect members who prefer reduced motion.
The widget should be added to a Discussion Page layout rather than pasted into the discussion body. That keeps the effect reusable and gives administrators control over where it appears.
Before you begin
Confirm that Custom Post Layouts are enabled in your community. You’ll also need access to Widget Builder and a staging environment where you can test the experience before committing it.
Because a widget added to a Discussion Page layout can appear on every discussion using that layout, use the targetPath option when the effect should be limited to one discussion. Enter the discussion’s pathname only - not the full domain or query string.
Step 1: Create the custom widget
In the Dashboard:
- Go to Appearance > Branding & Assets > Widget Builder.
- Select Create Widget > Custom Fragment.
- Give the widget a clear name, such as
Spring Emoji Spray. - Add the TypeScript code below to
index.tsx. - Add the CSS below to
styles.css. - Save and preview the widget on staging.
index.tsx
import React, { useMemo } from "react";
type Props = {
emoji?: string;
count?: number;
durationMs?: number;
visibleMs?: number;
enabled?: boolean;
targetPath?: string;
};
type SprayItem = {
id: number;
left: number;
top: number;
delay: number;
drift: number;
rotate: number;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
export default function SpringEmojiSpray({
emoji = "Spring is a great time to add a little personality to your community. In this #TipTuesday example, we’ll build a lightweight emoji spray that blooms across the discussion page when a member opens it.
The effect is decorative, reusable, and configurable. You can use flowers, leaves, stars, butterflies, confetti, or any other emoji that fits the moment.
What we’re building
We’ll create a custom Widget Builder widget that can:
Display a configurable emoji.Control the number of emojis and the animation speed.Dissolve automatically after five seconds.Run on every discussion page using the layout, or only on a selected discussion path.Optionally target a signed-in member using a profile field, such as a birthday.Respect members who prefer reduced motion.
The widget should be added to a Discussion Page layout rather than pasted into the discussion body. That keeps the effect reusable and gives administrators control over where it appears.
Before you begin
Confirm that Custom Post Layouts are enabled in your community. You’ll also need access to Widget Builder and a staging environment where you can test the experience before committing it.
Because a widget added to a Discussion Page layout can appear on every discussion using that layout, use the targetPath option when the effect should be limited to one discussion. Enter the discussion’s pathname only - not the full domain or query string.
Step 1: Create the custom widget
In the Dashboard:
Go to Appearance > Branding & Assets > Widget Builder.Select Create Widget > Custom Fragment.Give the widget a clear name, such as Spring Emoji Spray.Add the TypeScript code below to index.tsx.Add the CSS below to styles.css.Save and preview the widget on staging.
index.tsx
import React, { useMemo } from "react";
type Props = {
emoji?: string;
count?: number;
durationMs?: number;
visibleMs?: number;
enabled?: boolean;
targetPath?: string;
};
type SprayItem = {
id: number;
left: number;
top: number;
delay: number;
drift: number;
rotate: number;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
export default function SpringEmojiSpray({
emoji = "🌺",
count = 28,
durationMs = 4500,
visibleMs = 5000,
enabled = true,
targetPath = "",
}: Props) {
const currentPath = typeof window === "undefined" ? "" : window.location.pathname;
const requestedPath = targetPath.trim();
const isTargetPage =
!requestedPath ||
currentPath === requestedPath ||
currentPath.startsWith(`${requestedPath}/`);
const safeCount = clamp(Number(count) || 28, 8, 80);
const safeDuration = clamp(Number(durationMs) || 4500, 1500, 12000);
const safeVisibleMs = clamp(Number(visibleMs) || 5000, 1000, 30000);
const items = useMemo<SprayItem[]>(
() =>
Array.from({ length: safeCount }, (_, index) => ({
id: index,
left: 5 + ((index * 37) % 90),
top: 8 + ((index * 53) % 84),
delay: (index * 83) % 1200,
drift: (index % 2 === 0 ? 1 : -1) * (8 + (index % 5) * 4),
rotate: (index % 2 === 0 ? 1 : -1) * (8 + (index % 6) * 7),
})),
[safeCount],
);
if (!enabled || !isTargetPage || !emoji) {
return null;
}
return (
<div
className="emoji-spray"
aria-hidden="true"
style={{ "--visible-ms": `${safeVisibleMs}ms` } as React.CSSProperties}
>
{items.map((item) => (
<span
className="emoji-spray__item"
key={item.id}
style={
{
left: `${item.left}%`,
top: `${item.top}%`,
animationDelay: `${item.delay}ms`,
animationDuration: `${safeDuration}ms`,
"--drift": `${item.drift}px`,
"--rotate": `${item.rotate}deg`,
} as React.CSSProperties
}
>
{emoji}
</span>
))}
</div>
);
}
The default visibleMs value is 5000, so the full spray dissolves after five seconds. Administrators can change that value in the widget settings without changing the CSS.
styles.css
.emoji-spray {
position: fixed;
inset: 0;
z-index: 20;
overflow: hidden;
pointer-events: none;
animation: emoji-spray-dissolve var(--visible-ms, 5000ms) ease-in forwards;
}
@keyframes emoji-spray-dissolve {
0%,
72% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.emoji-spray__item {
position: absolute;
display: block;
font-size: clamp(1.25rem, 2.5vw, 2.25rem);
line-height: 1;
opacity: 0;
transform: translate(-50%, -50%) scale(0.25) rotate(0deg);
animation-name: emoji-spray-pop;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-fill-mode: both;
user-select: none;
}
@keyframes emoji-spray-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.25) rotate(0deg);
}
16% {
opacity: 1;
}
100% {
opacity: 0.92;
transform: translate(calc(-50% + var(--drift)), calc(-50% - var(--drift)))
scale(1) rotate(var(--rotate));
}
}
@media (prefers-reduced-motion: reduce) {
.emoji-spray__item {
opacity: 0.92;
animation: none;
transform: translate(-50%, -50%);
}
}
A few details make this safer for a live community:
pointer-events: none ensures the spray never blocks clicks.The animation is constrained to a reasonable size and duration.The reduced-motion rule gives members a calmer presentation.The effect is marked aria-hidden because it is decorative rather than informational.
Step 2: Add administrator settings
Expose the following options in the widget’s custom-options.json configuration, using the option schema available in your tenant:
emoji: text or select; default 🌺count: number; default 28; recommended range 8–80durationMs: number; default 4500; controls the individual emoji animationvisibleMs: number; default 5000; controls how long the complete spray remains visibleenabled: checkbox; default truetargetPath: text; leave blank for all pages using the layout, or enter one discussion’s pathname
Keeping these values configurable makes the widget easy to reuse for seasonal events without editing the component.
Step 3: Place the widget on the Discussion Page
After the widget is ready:
Go to Appearance > Layouts > Post Pages > Discussion Pages.Edit the Discussion Page layout.Add the Spring Emoji Spray widget to the desired section or column.Set the widget options.If the effect should run only on one discussion, enter that discussion’s pathname in targetPath.Save the layout and test the result on staging.
Do not paste the script into the discussion body. The post body is not the right scope for a page-wide effect, and scripts may be sanitized or behave inconsistently there.
Optional: Celebrate a member’s birthday
The same widget can be personalized for a signed-in member. For example, you could show the effect only when the member’s Birthday profile field matches today’s month and day.
The implementation should read the current user, retrieve the configured profile field, compare only the month and day, and return nothing when the condition is not met. The exact profile-field key and data shape depend on the community configuration, so do not assume that the visible label “Birthday” is the API key.
For a privacy-conscious implementation:
Use only the current member’s profile context.Do not display the stored birthday.Consider adding an opt-in field such as “Celebrate my birthday.”Test signed-out users, empty fields, locale formats, and timezone boundaries.Confirm that custom profile fields are available through the expanded user data used by the widget.
A birthday celebration should feel delightful, not surprising. Make the behavior easy for members to understand and easy for administrators to turn off.
Test before you commit
Before publishing, check the widget in several scenarios:
Desktop and mobile layouts.Signed-in and signed-out views.The selected discussion and a different discussion.A blank or invalid target path.Reduced-motion preferences.Different emoji choices and spray counts.The five-second dissolve and any custom duration.
Once testing is complete, commit the widget only when you are comfortable with its behavior. A future widget update can affect every layout where the widget is used.
Spring it up
That’s it - a small reusable widget that adds a seasonal moment without changing the underlying discussion experience.
What would you spray across a discussion page: 🌺 flowers, 🍃 leaves, ⭐ stars, 🦋 butterflies, or something completely different?
Happy spring-themed tinkering!
Learn more
Build Custom Widgets & FragmentsOverview of Custom Page LayoutsWidget Builder Best PracticesExample Tip Tuesday custom HTML widget
",
count = 28,
durationMs = 4500,
visibleMs = 5000,
enabled = true,
targetPath = "",
}: Props) {
const currentPath = typeof window === "undefined" ? "" : window.location.pathname;
const requestedPath = targetPath.trim();
const isTargetPage =
!requestedPath ||
currentPath === requestedPath ||
currentPath.startsWith(`${requestedPath}/`);
const safeCount = clamp(Number(count) || 28, 8, 80);
const safeDuration = clamp(Number(durationMs) || 4500, 1500, 12000);
const safeVisibleMs = clamp(Number(visibleMs) || 5000, 1000, 30000);
const items = useMemo<SprayItem[]>(
() =>
Array.from({ length: safeCount }, (_, index) => ({
id: index,
left: 5 + ((index * 37) % 90),
top: 8 + ((index * 53) % 84),
delay: (index * 83) % 1200,
drift: (index % 2 === 0 ? 1 : -1) * (8 + (index % 5) * 4),
rotate: (index % 2 === 0 ? 1 : -1) * (8 + (index % 6) * 7),
})),
[safeCount],
);
if (!enabled || !isTargetPage || !emoji) {
return null;
}
return (
<div
className="emoji-spray"
aria-hidden="true"
style={{ "--visible-ms": `${safeVisibleMs}ms` } as React.CSSProperties}
>
{items.map((item) => (
<span
className="emoji-spray__item"
key={item.id}
style={
{
left: `${item.left}%`,
top: `${item.top}%`,
animationDelay: `${item.delay}ms`,
animationDuration: `${safeDuration}ms`,
"--drift": `${item.drift}px`,
"--rotate": `${item.rotate}deg`,
} as React.CSSProperties
}
>
{emoji}
</span>
))}
</div>
);
}
The default visibleMs value is 5000, so the full spray dissolves after five seconds. Administrators can change that value in the widget settings without changing the CSS.
styles.css
.emoji-spray {
position: fixed;
inset: 0;
z-index: 20;
overflow: hidden;
pointer-events: none;
animation: emoji-spray-dissolve var(--visible-ms, 5000ms) ease-in forwards;
}
@keyframes emoji-spray-dissolve {
0%,
72% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.emoji-spray__item {
position: absolute;
display: block;
font-size: clamp(1.25rem, 2.5vw, 2.25rem);
line-height: 1;
opacity: 0;
transform: translate(-50%, -50%) scale(0.25) rotate(0deg);
animation-name: emoji-spray-pop;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-fill-mode: both;
user-select: none;
}
@keyframes emoji-spray-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.25) rotate(0deg);
}
16% {
opacity: 1;
}
100% {
opacity: 0.92;
transform: translate(calc(-50% + var(--drift)), calc(-50% - var(--drift)))
scale(1) rotate(var(--rotate));
}
}
@media (prefers-reduced-motion: reduce) {
.emoji-spray__item {
opacity: 0.92;
animation: none;
transform: translate(-50%, -50%);
}
}
A few details make this safer for a live community:
pointer-events: none ensures the spray never blocks clicks.- The animation is constrained to a reasonable size and duration.
- The reduced-motion rule gives members a calmer presentation.
- The effect is marked
aria-hidden because it is decorative rather than informational.
Step 2: Add administrator settings
Expose the following options in the widget’s custom-options.json configuration, using the option schema available in your tenant:
emoji: text or select; default 🌺count: number; default 28; recommended range 8–80durationMs: number; default 4500; controls the individual emoji animationvisibleMs: number; default 5000; controls how long the complete spray remains visibleenabled: checkbox; default truetargetPath: text; leave blank for all pages using the layout, or enter one discussion’s pathname
Keeping these values configurable makes the widget easy to reuse for seasonal events without editing the component.
Step 3: Place the widget on the Discussion Page
After the widget is ready:
- Go to Appearance > Layouts > Post Pages > Discussion Pages.
- Edit the Discussion Page layout.
- Add the
Spring Emoji Spray widget to the desired section or column. - Set the widget options.
- If the effect should run only on one discussion, enter that discussion’s pathname in
targetPath. - Save the layout and test the result on staging.
Do not paste the script into the discussion body. The post body is not the right scope for a page-wide effect, and scripts may be sanitized or behave inconsistently there.
Optional: Celebrate a member’s birthday
The same widget can be personalized for a signed-in member. For example, you could show the effect only when the member’s Birthday profile field matches today’s month and day.
The implementation should read the current user, retrieve the configured profile field, compare only the month and day, and return nothing when the condition is not met. The exact profile-field key and data shape depend on the community configuration, so do not assume that the visible label “Birthday” is the API key.
For a privacy-conscious implementation:
- Use only the current member’s profile context.
- Do not display the stored birthday.
- Consider adding an opt-in field such as “Celebrate my birthday.”
- Test signed-out users, empty fields, locale formats, and timezone boundaries.
- Confirm that custom profile fields are available through the expanded user data used by the widget.
A birthday celebration should feel delightful, not surprising. Make the behavior easy for members to understand and easy for administrators to turn off.
Test before you commit
Before publishing, check the widget in several scenarios:
- Desktop and mobile layouts.
- Signed-in and signed-out views.
- The selected discussion and a different discussion.
- A blank or invalid target path.
- Reduced-motion preferences.
- Different emoji choices and spray counts.
- The five-second dissolve and any custom duration.
Once testing is complete, commit the widget only when you are comfortable with its behavior. A future widget update can affect every layout where the widget is used.
Spring it up
That’s it - a small reusable widget that adds a seasonal moment without changing the underlying discussion experience.
What would you spray across a discussion page: 🌺 flowers, 🍃 leaves, ⭐ stars, 🦋 butterflies, or something completely different?
Happy spring-themed tinkering!
Learn more