An EV charge card with a retargeting battery fill, charge rate, and time-to-full in tabular figures.
npx shadcn@latest add @paragon/ev-charging-card"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Zap, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
export interface EvChargingCardProps extends React.ComponentProps<"div"> {
/** Current charge percentage, 0–100. */
charge?: number;
/** Charge rate in kW. */
rate?: number;
/** Minutes remaining to full. */
minutesToFull?: number;
/** Vehicle name. */
vehicle?: string;
/** Renders the final fill immediately, no fill-in. */
static?: boolean;
}
/**
* An EV charging card. The battery level is a vertical fill whose height comes
* from a transform (scaleY on a bottom-anchored bar), so value changes retarget
* mid-flight via a CSS transition rather than restarting, and never animate
* layout. Charge rate and time-to-full sit in tabular figures. On first view
* the fill rises from empty; reduced motion (or `static`) shows the final
* level immediately.
*/
export function EvChargingCard({
charge = 64,
rate = 48,
minutesToFull = 26,
vehicle = "Model Y — Garage",
static: isStatic = false,
className,
...props
}: EvChargingCardProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const filled = !animate || inView;
const level = Math.max(0, Math.min(100, charge));
return (
<div
ref={ref}
data-slot="ev-charging-card"
className={cn(
"flex w-full max-w-xs items-center gap-5 rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
{/* Battery */}
<div className="relative shrink-0">
<div
className="absolute -top-1 left-1/2 h-1 w-4 -translate-x-1/2 rounded-t-sm bg-border"
aria-hidden
/>
<div
role="meter"
aria-valuenow={Math.round(level)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Battery charge"
className="relative h-24 w-14 overflow-hidden rounded-md border-2 border-border bg-muted"
>
<div
aria-hidden
className="absolute inset-x-0 bottom-0 origin-bottom bg-success"
style={{
height: "100%",
transform: `scaleY(${filled ? level / 100 : 0})`,
transition: animate
? "transform 800ms var(--ease-out)"
: undefined,
}}
/>
<span className="absolute inset-0 z-10 flex items-center justify-center text-sm font-semibold tabular-nums text-foreground mix-blend-difference">
{Math.round(level)}%
</span>
</div>
</div>
{/* Readouts */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{vehicle}</div>
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-success">
<Zap className="size-3.5" />
Charging
</div>
<dl className="mt-3 space-y-2">
<Stat
icon={Zap}
label="Rate"
value={`${rate.toFixed(0)} kW`}
/>
<Stat
icon={Clock}
label="To full"
value={`${Math.floor(minutesToFull / 60) > 0 ? `${Math.floor(minutesToFull / 60)}h ` : ""}${minutesToFull % 60}m`}
/>
</dl>
</div>
</div>
);
}
function Stat({
icon: Icon,
label,
value,
}: {
icon: React.ElementType;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-2 text-sm">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<dt className="text-muted-foreground">{label}</dt>
<dd className="ml-auto font-semibold tabular-nums">{value}</dd>
</div>
);
}