Climate

Energy Flow

A solar-to-battery-to-home diagram with power marching along the connectors as flowing dashes, paused offscreen and flat under reduced motion.

Install

npx shadcn@latest add @paragon/energy-flow

energy-flow.tsx

"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>
  );
}