Logistics

SLA Tracker

On-time-delivery SLA tiles showing measured rate against target with breach flags and period-over-period deltas.

Install

npx shadcn@latest add @paragon/sla-tracker

Also installs: number-ticker, tooltip

sla-tracker.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { TrendingDown, TrendingUp, AlertTriangle } from "lucide-react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export interface SlaTile {
  label: string;
  /** Measured on-time rate 0–1. */
  value: number;
  /** SLA target 0–1. Below target flags a breach. */
  target: number;
  /** Optional delta vs prior period, e.g. +0.021. */
  delta?: number;
}

export interface SlaTrackerProps extends React.ComponentProps<"div"> {
  tiles: SlaTile[];
  /** Opt out of the arc sweep, ticker and reveal. */
  static?: boolean;
}

// A ring drawn on a single shared scale: the arc length is stroke-dashoffset
// off the full circumference, so the fill maps linearly to the rate.
const R = 15.9155; // circumference = 100, so 1% = 1 length unit
const CIRC = 2 * Math.PI * R;

/**
 * On-time-delivery SLA tiles. Each tile pairs the measured rate against its
 * target on a single scale: a computed progress ring sweeps to the rate (its
 * length is stroke-dashoffset off a circumference of exactly 100 units, so
 * percent maps linearly), a tick on the ring marks the target, and the rate
 * counts up on a number-ticker. A tile below target flags a breach with the
 * destructive token; within a warning band it tints warning. Tiles reveal with
 * the house rise, stagger in, and expose their target + delta on hover/focus.
 * Reduced motion (or `static`) shows final values with no sweep.
 */
export function SlaTracker({
  tiles,
  static: isStatic = false,
  className,
  ...props
}: SlaTrackerProps) {
  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 armed = !animate || inView;

  if (tiles.length === 0) {
    return (
      <div
        data-slot="sla-tracker"
        className={cn(
          "flex w-full max-w-lg items-center justify-center rounded-xl border border-dashed border-border px-4 py-10 text-sm text-muted-foreground",
          className,
        )}
      >
        No SLA metrics for this period.
      </div>
    );
  }

  return (
    <TooltipProvider>
      <style href="paragon-sla-tracker" precedence="paragon">{`
        @keyframes pg-sla-rise {
          from { opacity: 0; transform: translateY(12px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-sla-tile] { animation: none !important; }
        }
      `}</style>
      <div
        ref={ref}
        data-slot="sla-tracker"
        className={cn(
          "grid w-full max-w-lg grid-cols-2 gap-3 sm:grid-cols-3",
          className,
        )}
        {...props}
      >
        {tiles.map((tile, i) => {
          const breach = tile.value < tile.target;
          // Warning band: within 1.5 points above target but not yet breaching.
          const warn = !breach && tile.value < tile.target + 0.015;
          const tone = breach ? "destructive" : warn ? "warning" : "success";
          const toneVar = `var(--color-${tone})`;

          // Computed arc length on the shared 0..100 scale.
          const clamped = Math.max(0, Math.min(1, tile.value));
          const shown = armed ? clamped : 0;
          const targetPct = Math.max(0, Math.min(1, tile.target));

          return (
            <Tooltip key={tile.label}>
              <TooltipTrigger asChild>
                <button
                  type="button"
                  data-sla-tile
                  aria-label={`${tile.label}: ${(tile.value * 100).toFixed(
                    1,
                  )} percent on-time, target ${(tile.target * 100).toFixed(
                    1,
                  )} percent${breach ? ", breach" : ""}`}
                  className={cn(
                    "pressable group relative flex flex-col items-start overflow-hidden rounded-xl bg-card p-4 text-left outline-none shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    breach && "bg-destructive/[0.04]",
                    warn && "bg-warning/[0.05]",
                  )}
                  style={
                    animate
                      ? {
                          animation: `pg-sla-rise var(--duration-base) var(--ease-out) both`,
                          animationDelay: `${i * 60}ms`,
                        }
                      : undefined
                  }
                >
                  <div className="flex w-full items-start justify-between gap-2">
                    <p className="min-w-0 truncate text-xs font-medium text-muted-foreground">
                      {tile.label}
                    </p>
                    {breach && (
                      <AlertTriangle
                        aria-hidden
                        className="size-3.5 shrink-0 text-destructive"
                      />
                    )}
                  </div>

                  <div className="mt-2 flex items-center gap-3">
                    {/* Computed progress ring */}
                    <span className="relative shrink-0" aria-hidden>
                      <svg
                        viewBox="0 0 40 40"
                        className="size-10 -rotate-90"
                      >
                        <circle
                          cx="20"
                          cy="20"
                          r={R}
                          fill="none"
                          className="stroke-border"
                          strokeWidth={3}
                          vectorEffect="non-scaling-stroke"
                        />
                        <circle
                          cx="20"
                          cy="20"
                          r={R}
                          fill="none"
                          stroke={toneVar}
                          strokeWidth={3}
                          strokeLinecap="round"
                          vectorEffect="non-scaling-stroke"
                          strokeDasharray={CIRC}
                          strokeDashoffset={CIRC * (1 - shown)}
                          className="transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none"
                        />
                        {/* Target tick on the same scale */}
                        <line
                          x1="20"
                          y1="20"
                          x2="20"
                          y2={20 - R - 2.2}
                          className="stroke-foreground/45"
                          strokeWidth={1.5}
                          strokeLinecap="round"
                          vectorEffect="non-scaling-stroke"
                          style={{
                            transform: `rotate(${targetPct * 360}deg)`,
                            transformOrigin: "20px 20px",
                          }}
                        />
                      </svg>
                    </span>

                    <div className="min-w-0">
                      <p
                        className={cn(
                          "text-2xl font-semibold leading-none tabular-nums",
                          breach
                            ? "text-destructive"
                            : warn
                              ? "text-warning"
                              : "text-foreground",
                        )}
                      >
                        <NumberTicker
                          value={tile.value * 100}
                          static={isStatic}
                          formatOptions={{
                            minimumFractionDigits: 1,
                            maximumFractionDigits: 1,
                          }}
                        />
                        <span className="text-base">%</span>
                      </p>
                      <p className="mt-1 truncate text-[11px] tabular-nums text-muted-foreground">
                        Target {(tile.target * 100).toFixed(1)}%
                      </p>
                    </div>
                  </div>

                  {tile.delta !== undefined && (
                    <span
                      className={cn(
                        "mt-2 flex items-center gap-0.5 text-[11px] font-medium tabular-nums",
                        tile.delta >= 0 ? "text-success" : "text-destructive",
                      )}
                    >
                      {tile.delta >= 0 ? (
                        <TrendingUp aria-hidden className="size-3" />
                      ) : (
                        <TrendingDown aria-hidden className="size-3" />
                      )}
                      {tile.delta >= 0 ? "+" : ""}
                      {(tile.delta * 100).toFixed(1)} pts vs. prior
                    </span>
                  )}
                </button>
              </TooltipTrigger>
              <TooltipContent side="top">
                <span className="font-medium">{tile.label}</span>
                <span className="ml-1.5 tabular-nums text-primary-foreground/70">
                  {(tile.value * 100).toFixed(1)}% / target{" "}
                  {(tile.target * 100).toFixed(1)}%
                  {breach ? " · breach" : warn ? " · at risk" : " · on track"}
                </span>
              </TooltipContent>
            </Tooltip>
          );
        })}
      </div>
    </TooltipProvider>
  );
}