A solar-to-battery-to-home diagram with power marching along the connectors as flowing dashes, paused offscreen and flat under reduced motion.
npx shadcn@latest add @paragon/energy-flow"use client";
import * as React from "react";
import { Sun, BatteryCharging, House } from "lucide-react";
import { cn } from "@/lib/utils";
export interface EnergyFlowProps extends React.ComponentProps<"div"> {
/** Solar generation in kW. */
solar?: number;
/** Battery charge percentage, 0–100. */
battery?: number;
/** Home draw in kW. */
home?: number;
/** Freeze the flowing dashes. */
static?: boolean;
}
/**
* A source to battery to home energy diagram. Power flows along the connectors
* as dashes marching via an animated stroke-dashoffset keyframe (the only place
* a loop is allowed — constant motion, linear). The flow pauses when offscreen
* and freezes flat under reduced motion. Nodes carry live kW / % readouts in
* tabular figures. All keyframes are hoisted to head via precedence.
*/
export function EnergyFlow({
solar = 4.2,
battery = 76,
home = 2.8,
static: isStatic = false,
className,
...props
}: EnergyFlowProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setActive(entry.isIntersecting),
{ threshold: 0.2 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const flowing = active && !isStatic;
return (
<div
ref={ref}
data-slot="energy-flow"
className={cn(
"w-full max-w-md rounded-xl bg-card p-6 shadow-border",
className,
)}
{...props}
>
<style href="paragon-energy-flow" precedence="paragon">{`
@keyframes paragon-energy-flow-march {
to { stroke-dashoffset: -16; }
}
@media (prefers-reduced-motion: reduce) {
[data-flow-line] { animation: none !important; }
}
`}</style>
<div className="relative flex items-center justify-between">
<svg
aria-hidden
className="pointer-events-none absolute inset-0 h-full w-full text-success"
preserveAspectRatio="none"
viewBox="0 0 100 100"
>
<line
data-flow-line
x1="16" y1="50" x2="50" y2="50"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
strokeDasharray="4 4"
style={flowing ? { animation: "paragon-energy-flow-march 0.8s linear infinite" } : undefined}
/>
<line
data-flow-line
x1="50" y1="50" x2="84" y2="50"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
strokeDasharray="4 4"
style={flowing ? { animation: "paragon-energy-flow-march 0.8s linear infinite" } : undefined}
/>
</svg>
<Node
icon={Sun}
tint="text-warning"
label="Solar"
value={`${solar.toFixed(1)} kW`}
/>
<Node
icon={BatteryCharging}
tint="text-success"
label="Battery"
value={`${Math.round(battery)}%`}
/>
<Node
icon={House}
tint="text-foreground"
label="Home"
value={`${home.toFixed(1)} kW`}
/>
</div>
</div>
);
}
function Node({
icon: Icon,
tint,
label,
value,
}: {
icon: React.ElementType;
tint: string;
label: string;
value: string;
}) {
return (
<div className="relative z-10 flex flex-col items-center gap-2">
<span
className={cn(
"flex size-12 items-center justify-center rounded-full bg-card shadow-border",
tint,
)}
>
<Icon className="size-5" />
</span>
<div className="text-center">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-sm font-semibold tabular-nums">{value}</div>
</div>
</div>
);
}