Building Real Time Gradient: Turning CSS Gradients Into a Runtime UI Primitive
How a small experiment with CSS gradients evolved into a framework-agnostic JavaScript library for controlling gradients at runtime.
I've always liked small UI details that make an interface feel a little more alive.
Gradients are a good example.
Usually, you define one in CSS, give it a couple of colors and a direction, and you're done:
.hero {
background: linear-gradient(to right, #ff7e5f, #feb47b);
}There's nothing wrong with that. In fact, that's exactly what CSS gradients are good at.
But at some point I started wondering:
What if the gradient wasn't just styling, but something the application could actually control?
What if its colors could change at runtime?
What if it could react to application state, transition between different palettes, respond to temporary events, or even change automatically depending on the time of day?
That question eventually became Real Time Gradient.
Real Time Gradient is a lightweight, framework-agnostic JavaScript library for creating smooth, customizable, real-time gradients with control over colors, direction, animation, effects, and scheduling.
The Original Idea
The first version of the idea was much simpler than the library that exists today.
I wasn't trying to build a gradient engine.
The browser already has one.
CSS can create a gradient with something as simple as:
.hero {
background: linear-gradient(to right, #ff7e5f, #feb47b);
}The browser handles the rendering, interpolation, and compositing.
So the question wasn't:
How do I render a gradient?
it was:
How do I make the gradient something my application can control?
That distinction became the foundation of the project.
Designing the Runtime API
The first version of the API could have been very simple.
Something like:
gradient.setColors([...]);But as the behavior of the library grew, that kind of API started becoming ambiguous.
Changing the gradient permanently is different from temporarily changing it. Stopping an effect is different from destroying the gradient.
Scheduling a gradient is different from manually triggering one. Those differences eventually became reflected in the API.
gradient.persistEffect(["#9370db", "#4682b4"], 2000);persistEffect() represents a change to the gradient's ongoing visual state.
If the application wants to temporarily alter the gradient instead, it can use:
gradient.triggerEffect({
hue: "gold",
duration: 2000,
});That distinction might seem small, but it gives the API a useful semantic layer. The method tells you something about the intention behind the change.
The application isn't just saying:
Set these colors.
It's saying:
This should become the new visual state.
Or:
This is a temporary visual event.
That became an important part of how I thought about the API.
The Gradient Lifecycle
Once a gradient becomes stateful, animated, and scheduled, lifecycle becomes an important part of the problem. A static CSS declaration doesn't really have much of a lifecycle.
It exists as part of the stylesheet. A runtime gradient is different.
It can create timers.
It can maintain animation state.
It can react to effects.
It can update the DOM.
It can schedule future changes.
That means there needs to be a clear way to stop what the runtime is doing.
For example:
gradient.stopEffects();allows active effects to be stopped without necessarily destroying the gradient itself.
And when the gradient is no longer needed:
gradient.destroy();provides a way to tear down the runtime completely.
This becomes particularly important when using the library inside component-based applications.
A component might create a gradient when it mounts:
useEffect(() => {
const gradient = DynamicGradient.init("#hero", {
colors: ["#ff7e5f", "#feb47b"],
});
return () => {
gradient.destroy();
};
}, []);The library doesn't need to understand React's lifecycle.
React owns the component lifecycle.
The application decides when the gradient should exist.
Dynamic Gradient is responsible for cleaning up the resources associated with that instance when destroy() is called. That separation keeps the core library framework-agnostic while still making it usable inside framework-managed applications.
And this was an important realization during the project:
Once something has runtime behavior, cleanup is part of its API, not an afterthought.
Small primitives, different behaviors
The public surface of the library is intentionally relatively small. At its core, the runtime revolves around a handful of operations:
init()
↓
establish the gradient
persistEffect()
↓
change the persistent visual state
triggerEffect()
↓
apply a temporary visual change
stopEffects()
↓
stop active effects
destroy()
↓
clean up the runtimeNone of these methods are particularly complicated on their own.
The interesting part is how they compose.
A gradient can be initialized once and then become part of a larger application's visual system.
That meant I didn't want the API to expose every internal implementation detail. The more the library could express intent instead of implementation, the easier it was to evolve the internals without forcing consumers to understand them.
When the Gradient Became More Than a Background
At some point during development, the abstraction started becoming bigger than the original problem. The initial goal was simply to make a gradient change dynamically.
Then came transitions.
Then temporary effects.
Then scheduling.
Then different gradient types.
Then using the gradient as a text fill.
The API started looking less like a background helper and more like a small visual primitive.
For example, a gradient can be scheduled around the clock:
schedule: [
{
time: "06:00",
colors: ["#7fff00ff", "#ffb347ff", "#ff69b4ff"],
},
{
time: "18:00",
colors: ["#ff4500ff", "#9370dbff", "#4682b4ff"],
},
];It can also be applied to text:
const gradient = DynamicGradient.init("#title", {
colors: ["#ff7e5f", "#feb47b"],
textClip: true,
});And the same runtime can handle different gradient types:
const gradient = DynamicGradient.init("#hero", {
type: "radial",
colors: ["#7f5af0", "#2cb67d"],
});None of these features were really the original problem.
But they all fit naturally once the underlying abstraction became:
A runtime-controlled visual gradient.
That distinction is important.
I wasn't trying to build a collection of unrelated gradient features.
I was exploring what becomes possible when a visual property is treated as a first-class runtime primitive.
And that is probably the most interesting part of the project to me.
From CSS Property to Runtime State
A static gradient is essentially a declaration:
background: linear-gradient(...);But once an application needs to change that gradient dynamically, the gradient starts behaving more like state.
The model becomes something closer to:
Application
│
▼
Real Time Gradient
│
├── colors
├── direction
├── type
├── transitions
├── effects
└── schedule
│
▼
CSS gradient
│
▼
BrowserReal Time Gradient doesn't replace the browser's rendering capabilities. It sits between application code and the CSS representation of the gradient.
That made the initial API fairly straightforward:
const gradient = DynamicGradient.init("#hero", {
type: "linear",
direction: "to right",
colors: ["#ff7e5f", "#feb47b"],
});Now the gradient has an object representing it.
The application can interact with that object instead of treating the gradient as a static string inside a stylesheet.
The important part of this API isn't really init() itself. It's the fact that initialization gives the application a runtime object representing the gradient.
From there, the gradient can have behavior.
-
It can transition.
-
It can temporarily react to an event.
-
It can change according to a schedule.
-
It can be stopped.
-
It can eventually be destroyed.
The gradient has gone from being a value:
linear-gradient(...)
to something closer to a small stateful UI primitive:
Gradient
├── state
├── behavior
└── lifecycleAnd that was the point where the project started becoming more interesting than a helper for generating CSS strings.
Why JavaScript?
At first glance, JavaScript might seem unnecessary here.
CSS already gives us gradients. It can handle linear and radial gradients, multiple color stops, directions, and a lot of the visual behavior we need.
So why add another layer?
The answer isn't that CSS can't create dynamic visuals. It can.
The problem is where the decision to change the visual state comes from.
CSS describes how something should look.
JavaScript is often responsible for knowing when and why it should change.
Imagine a gradient reacting to an application event:
user interaction
↓
application state
↓
visual state changes
↓
gradient changesOr a more concrete example:
if (userCompletedAction) {
gradient.triggerEffect({
hue: "gold",
duration: 2000,
});
}The gradient isn't the source of that decision.
The application is.
That's where JavaScript becomes useful.
The same applies to time-based behavior. A schedule such as:
schedule: [
{
time: "06:00",
colors: ["#7fff00ff", "#ffb347ff", "#ff69b4ff"],
},
{
time: "18:00",
colors: ["#ff4500ff", "#9370dbff", "#4682b4ff"],
},
];Isn't really a styling concern anymore.
Something needs to know what time it is, determine which state should be active, and tell the gradient to transition. That's application behavior.
JavaScript is already the natural place for that logic.
The goal wasn't to replace CSS
This distinction was important to me while building the library. I didn't want to create a JavaScript version of CSS gradients. That would mean taking something the browser already does well and rebuilding it somewhere else.
Instead, the idea was to let CSS remain responsible for what it is good at:
CSS
└── describe and render the gradientwhile JavaScript handles the runtime behavior:
JavaScript
├── decide when the gradient changes
├── manage the current visual state
├── trigger transitions
├── schedule changes
└── manage lifecycleThe library sits between those two responsibilities.
Application
│
│ "change the visual state"
▼
Real Time Gradient
│
│ "translate state into CSS"
▼
CSS
│
▼
BrowserThat separation ended up being one of the most important design decisions in the project.
The browser remains the rendering engine. The application remains in control.
Real Time Gradient simply gives the gradient a runtime API.
What Building a Small Open-Source Library Actually Changed
There is a difference between building something that works and building something that other people can use.
When Dynamic Gradient was just an experiment, I could make assumptions.
I knew how the code worked.
I knew what inputs I expected.
I knew how the runtime behaved.
Once it became an npm package, those assumptions stopped being private implementation details.
They became part of a public interface.
Suddenly questions like these mattered:
- What happens if the target doesn't exist?
- What are the default options?
- What exactly does an effect mean?
- How should cleanup work?
- What happens when a schedule crosses midnight?
- What should TypeScript expose?
- How much of the implementation should consumers know about?
- Which behaviors are guarantees and which are implementation details?
The TypeScript definitions became part of that contract as well.
For example:
export interface GradientOptions {
type?: GradientType;
direction?: string;
colors?: string[];
transitionDuration?: number;
schedule?: ScheduleEntry[];
textClip?: boolean;
}That might look like a simple typing exercise, but public types force you to think carefully about what your API actually promises.
The same thing happened with documentation.
Writing documentation for your own code is surprisingly good at exposing ambiguity.
If you can't explain what a method does without describing the implementation behind it, the abstraction probably isn't finished yet.
That was one of the unexpected benefits of turning the experiment into an open-source package.
It forced me to think less about:
How does my code work?
and more about:
What should someone else reasonably expect this code to do?
What I Learned
The biggest lesson wasn't really about gradients. It was about abstraction. The browser already had the rendering primitive.
I didn't need to recreate it. The interesting problem was identifying the missing layer between a static CSS declaration and application-driven visual behavior. That led to a few things I found particularly useful.
Don't rebuild what the platform already does well
CSS and the browser are very good at rendering gradients. Trying to replace that would have added complexity without solving the actual problem.
The useful abstraction was the runtime control layer.
APIs should communicate intent
There's a meaningful difference between:
setColors(...)and:
persistEffect(...)or:
triggerEffect(...)The latter communicates why the operation is happening, not just what data is being passed.
That makes the API easier to reason about and leaves more freedom to change the implementation underneath it.
Small projects can expose real architectural problems
Dynamic Gradient is a relatively small library.
But even a small runtime needs to answer real engineering questions:
- state
- lifecycle
- scheduling
- cleanup
- public interfaces
- typing
- packaging
- framework boundaries
You don't need a huge application to encounter these problems.
Sometimes a small project is actually a good environment for learning them because the boundaries are easier to see.
What's Next?
I don't have a huge roadmap for Dynamic Gradient.
That's intentional.
One of the things I like about the project is that it can remain small while still being useful.
There are definitely areas that could evolve:
- richer animation controls
- more expressive effects
- additional gradient primitives
- improved interpolation
- more examples and demos
- framework integrations where they actually provide value
But I don't want to add features just to make the API larger.
I'd rather keep the underlying idea clear:
Give applications a simple way to treat gradients as controllable runtime UI primitives.
If people find interesting uses for that abstraction, that's probably a better direction for the project than trying to predict every possible feature myself.
Final Thoughts
A gradient is usually just a background.
At least, that's how I thought about it when I started.
But once you give that gradient state, behavior, transitions, scheduling, and a lifecycle, it starts looking a lot less like a CSS declaration and a lot more like a UI primitive.
That's really what Dynamic Gradient became.
Not a replacement for CSS.
Not a rendering engine.
Not a framework.
Just a small layer between application state and something the browser already knows how to render.
And that's probably the part of the project I'm most interested in.
The library started with a fairly simple question:
What if a gradient wasn't just styling, but something the application could actually control?
It turns out that question was more interesting than the gradient itself.