Climate

Home Energy Dashboard

A home energy block with a live grid-draw meter, solar/battery/load tiles, and today's running cost, tweening without layout thrash.

Install

npx shadcn@latest add @paragon/home-energy-dashboard

home-energy-dashboard.tsx

"use client";

import * as React from "react";
import { Zap, Sun, BatteryCharging, Gauge, ArrowDown, ArrowUp } from "lucide-react";
import { cn } from "@/lib/utils";

interface Tile {
  label: string;
  value: string;
  unit: string;
  icon: React.ElementType;
  tint: string;
  delta?: { dir: "up" | "down"; text: string };
}

export interface HomeEnergyDashboardProps extends React.ComponentProps<"div"> {
  /** Live grid draw in kW (negative = exporting to grid). */
  draw?: number;
  /** Solar generation in kW. */
  solar?: number;
  /** Battery state of charge, 0–100. */
  battery?: number;
  /** Today's cost so far in currency units. */
  cost?: number;
  /** Currency symbol. */
  currency?: string;
}

/**
 * A home energy overview: a headline live-draw meter with a proportional bar
 * (import vs. export), a row of usage tiles, and today's running cost. The draw
 * bar and battery fill tween via CSS width-free transforms (scaleX) so nothing
 * layout-thrashes, and everything is deterministic with tabular figures.
 */
export function HomeEnergyDashboard({
  draw = -1.4,
  solar = 4.6,
  battery = 78,
  cost = 3.42,
  currency = "$",
  className,
  ...props
}: HomeEnergyDashboardProps) {
  const exporting = draw < 0;
  const magnitude = Math.abs(draw);
  const barScale = Math.min(magnitude / 6, 1);

  const tiles: Tile[] = [
    {
      label: "Solar",
      value: solar.toFixed(1),
      unit: "kW",
      icon: Sun,
      tint: "text-warning",
      delta: { dir: "up", text: "peak" },
    },
    {
      label: "Battery",
      value: String(Math.round(battery)),
      unit: "%",
      icon: BatteryCharging,
      tint: "text-success",
      delta: { dir: "up", text: "charging" },
    },
    {
      label: "Home load",
      value: (solar - draw).toFixed(1),
      unit: "kW",
      icon: Gauge,
      tint: "text-foreground",
    },
  ];

  return (
    <div
      data-slot="home-energy-dashboard"
      className={cn(
        "w-full max-w-md rounded-2xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <span className="flex size-8 items-center justify-center rounded-lg bg-secondary text-foreground">
            <Zap className="size-4" />
          </span>
          <div>
            <h3 className="text-sm font-medium leading-tight">Home energy</h3>
            <p className="text-xs text-muted-foreground">Maple Street · live</p>
          </div>
        </div>
        <span
          className={cn(
            "flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
            exporting
              ? "bg-success/10 text-success"
              : "bg-secondary text-muted-foreground",
          )}
        >
          {exporting ? (
            <ArrowUp className="size-3" />
          ) : (
            <ArrowDown className="size-3" />
          )}
          {exporting ? "Exporting" : "Importing"}
        </span>
      </div>

      <div className="mt-4 rounded-xl bg-secondary/50 p-4">
        <div className="flex items-baseline justify-between">
          <span className="text-xs text-muted-foreground">
            {exporting ? "Sending to grid" : "Drawing from grid"}
          </span>
          <span className="text-2xl font-semibold tabular-nums">
            {magnitude.toFixed(1)}
            <span className="ml-1 text-sm font-normal text-muted-foreground">
              kW
            </span>
          </span>
        </div>
        <div className="mt-2 h-2 overflow-hidden rounded-full bg-border/60">
          <div
            className={cn(
              "h-full origin-left rounded-full transition-transform duration-500 ease-out",
              exporting ? "bg-success" : "bg-warning",
            )}
            style={{ transform: `scaleX(${barScale})` }}
          />
        </div>
      </div>

      <div className="mt-4 grid grid-cols-3 gap-2">
        {tiles.map((t) => (
          <div key={t.label} className="rounded-xl bg-secondary/50 p-3">
            <t.icon className={cn("size-4", t.tint)} />
            <div className="mt-2 text-lg font-semibold leading-none tabular-nums">
              {t.value}
              <span className="ml-0.5 text-xs font-normal text-muted-foreground">
                {t.unit}
              </span>
            </div>
            <div className="mt-1 text-xs text-muted-foreground">{t.label}</div>
          </div>
        ))}
      </div>

      <div className="mt-4 flex items-center justify-between border-t border-border pt-3">
        <span className="text-xs text-muted-foreground">Cost today</span>
        <span className="text-sm font-semibold tabular-nums">
          {currency}
          {cost.toFixed(2)}
        </span>
      </div>
    </div>
  );
}