A home energy block with a live grid-draw meter, solar/battery/load tiles, and today's running cost, tweening without layout thrash.
npx shadcn@latest add @paragon/home-energy-dashboard"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>
);
}