Pulsating Button
A button is pressed briefly and generates a series of concentric pulses radiating outwards in the shape of the button’s outline.
Requirements
- The button is centered on the screen.
- The button has text and an icon inside it.
- The text and icon are spaced around horizontally.
- The text and icon positions should be swappable
- Upon click, the button rapidly scales down and then returns to its original size, simulating the effect of a physical press.
- The pulse effect is triggered when the release animation is complete.
- The pulse effect creates expanding concentric rings that match the button’s outline shape.
- The background color of the button is user-configurable.
- The foreground color is user-configurable.
Schema
const schema = z.object({
/** The radius of the button's border */
borderRadius: z.number(),
/** The background color of the button */
backgroundColor: z.string(),
/** The foreground color of the button */
foregroundColor: z.string(),
/** The horizontal padding of the button */
paddingHorizontal: z.number(),
/** The vertical padding of the button */
paddingVertical: z.number(),
/** The font size of the button */
fontSize: z.number(),
/** The text of the button */
text: z.string(),
/** The icon of the button */
icon: iconNameSchema,
/** The color of the icon's stroke */
strokeColor: z.string().optional(),
/** If true, the icon and text will be swapped. */
swapIconAndText: z.boolean(),
pulse: z.object({
/** Percentage of overlap between pulses */
overlap: z.number(),
/** Duration of each pulse */
duration: z.number(),
/** Number of pulses */
numberOfPulses: z.number(),
}),
click: z.object({
/** The frame at which the click animation starts */
start: z.number(),
/** The frame at which the click animation ends */
end: z.number(),
}),
});
Implementation
The button is centered on the screen
<Remotion.AbsoluteFill
style={{
backgroundColor: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Button */}
</Remotion.AbsoluteFill>
The text and icon positions should be swappable
Props
We can control the text and icon positions by passing a swapIconAndText
prop to the PulsatingButton
component, which holds a boolean value.
<Composition
id="PulsatingButton"
component={PulsatingButton}
defaultProps={{
swapIconAndText: true,
}}
/>
Then, in the button component, we can conditionally set flexDirection
based on the swapIconAndText
prop.
<div
style={{
display: "flex",
alignItems: "center",
flexDirection: swapIconAndText ? "row-reverse" : "row",
}}
>
{/* Icon */}
{iconSvg}
{/* Text */}
<span>{text}</span>
</div>
Upon click, the button rapidly scales down and then returns to its original size, simulating the effect of a physical press
To simulate a button press, we need to identify when click event starts (click.start
) and when it ends (click.end
), both in terms of frames.
Using these two parameters, we are able to smoothly interpolate the button’s scale based on the current frame
of the animation.
We also apply a clamp
extrapolation on both ends to ensure that the scale remains constrained between 0 and 1, preventing any unintended scaling effects.
const frame = Remotion.useCurrentFrame();
const pressScale = Remotion.interpolate(
frame, // Interpolated value
[
click.startAt, // Starting point
click.startAt + click.duration / 2, // Middle point
click.startAt + click.duration, // Ending point
],
[1, 0.9, 1], // Values to interpolate between
{ extrapolateRight: "clamp", extrapolateLeft: "clamp" }, // Restrict to the range defined
);
The pulse effect is triggered when the release animation is complete
The click.end
parameter tells us when the click event ends, which is precisely the moment the pulse effect should start.
We use Remotion.Sequence
component to trigger the pulse effect from click.end
onwards.
<Remotion.Sequence from={click.end}>
<Pulse />
</Remotion.Sequence>
The pulse effect consists of a series of concentric, expanding pulses that mirror the button’s outline shape
How to create the outline
To achieve the desired effect, we need to ensure that the pulse matches the exact shape of the button.
To do that, we create a button “double”, with some slight changes:
- Text and icon are rendered, but are not visible.
- The background color is set to
transparent
.
<div
style={{
backgroundColor: "transparent",
}}
>
{/* Icon */}
<Icon name={icon} size={60} color="transparent" />
{/* Text */}
<span
style={{
fontSize,
opacity: 0,
}}
>
{text}
</span>
</div>
The only visible element should be the outline, which is necessary for the pulse effect.
<div
style={{
backgroundColor: "transparent",
border: `2px solid ${strokeColor}`,
}}
>
{/* Icon */}
<Icon name={icon} size={60} color="transparent" />
{/* Text */}
<span
style={{
fontSize,
opacity: 0,
}}
>
{text}
</span>
</div>
How to make the outline pulse
First we need to increase the scale of the outline to create an expanding animation.
We do this by interpolating the current frame
, for a arbitrary duration (pulse.duration
), with a scale range of 1 to 3.5 — enough to expand the outline beyond the viewport’s boundaries.
By applying clamp
extrapolation, we ensure that the scale never exceeds the defined range, preventing any unintended scaling effects.
const scale = Remotion.interpolate(frame, [0, pulse.duration], [1, 3.5], {
extrapolateRight: "clamp",
extrapolateLeft: "clamp",
});
return (
<div
style={{
transform: `scale(${scale})`,
}}
>
{/* ... */}
</div>
);
Creating multiple pulses in a row
Now that we have a pulse effect, the next step is to replicate it multiple times, with a slight delay between each pulse, as if they’re rippling outwards.
To do this, we can use the Remotion.Sequence
component again, but this time we need to pass a from
prop that is the sum of the previous pulse’s duration
and overlap
.
{
pulses.map((_, index) => (
<Remotion.Sequence
key={index}
// `Math.floor()` ensures the start time is an integer, avoiding fractional frames
from={click.end + Math.floor(index * pulse.duration * pulse.overlap)}
// Each pulse lasts for `pulse.duration` frames
durationInFrames={pulse.duration}
>
<Pulse {...props} key={index} strokeColor={colorVariations[index]} />
</Remotion.Sequence>
));
}
pulse.duration
tells us how long each pulse lasts.pulse.overlap
shows how much of the next pulse (as a percentage) starts before the current one ends. This makes the pulses flow smoothly into each other.