Climate

EV Charging Card

An EV charge card with a retargeting battery fill, charge rate, and time-to-full in tabular figures.

Install

npx shadcn@latest add @paragon/ev-charging-card

ev-charging-card.tsx

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