PropTech

Neighborhood Score

Walk, transit, and school livability scores shown as sweeping radial dials with a band tint and overall average.

Install

npx shadcn@latest add @paragon/neighborhood-score

neighborhood-score.tsx

"use client";

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

export interface ScoreMetric {
  key: string;
  label: string;
  /** Score 0–100. */
  value: number;
  icon: "walk" | "transit" | "school";
  /** Optional detail shown in the tooltip. */
  detail?: string;
}

export interface NeighborhoodScoreProps extends React.ComponentProps<"div"> {
  metrics?: ScoreMetric[];
  /** Suppresses the dial sweep animation and number count-up. */
  static?: boolean;
}

const ICONS = {
  walk: Footprints,
  transit: TrainFront,
  school: GraduationCap,
} as const;

const BAND = {
  strong: { color: "var(--color-success)", text: "text-success", label: "Excellent" },
  fair: { color: "var(--color-warning)", text: "text-warning", label: "Fair" },
  weak: { color: "var(--color-destructive)", text: "text-destructive", label: "Limited" },
} as const;

function band(value: number) {
  if (value >= 80) return BAND.strong;
  if (value >= 55) return BAND.fair;
  return BAND.weak;
}

// Dial geometry — a single coordinate space, arc computed from circumference.
const SIZE = 64;
const THICKNESS = 6;
const CX = SIZE / 2;
const CY = SIZE / 2;
const R = (SIZE - THICKNESS) / 2 - 1;
const CIRC = 2 * Math.PI * R;

function Dial({
  metric,
  animate,
  armed,
  isStatic,
  index,
}: {
  metric: ScoreMetric;
  animate: boolean;
  armed: boolean;
  isStatic: boolean;
  index: number;
}) {
  const v = Math.min(Math.max(metric.value, 0), 100) / 100;
  const Icon = ICONS[metric.icon];
  const b = band(metric.value);
  // Full sweep matches value exactly: offset = C·(1 − value/100).
  const offset = CIRC * (1 - (armed ? v : 0));

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          aria-label={`${metric.label} score ${Math.round(metric.value)} of 100`}
          className={cn(
            "group flex flex-col items-center gap-2 rounded-lg p-1 outline-none",
            "transition-[scale] duration-150 ease-out active:not-disabled:scale-[0.97]",
            "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          )}
        >
          <div className="relative">
            <svg
              viewBox={`0 0 ${SIZE} ${SIZE}`}
              className="size-16 -rotate-90"
              aria-hidden
            >
              <circle
                cx={CX}
                cy={CY}
                r={R}
                fill="none"
                className="stroke-secondary"
                strokeWidth={THICKNESS}
                vectorEffect="non-scaling-stroke"
              />
              <circle
                cx={CX}
                cy={CY}
                r={R}
                fill="none"
                stroke={b.color}
                strokeWidth={THICKNESS}
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
                strokeDasharray={CIRC}
                strokeDashoffset={offset}
                style={{
                  transition: animate
                    ? `stroke-dashoffset 700ms var(--ease-out) ${index * 90}ms, stroke 300ms var(--ease-out)`
                    : "stroke 300ms var(--ease-out)",
                }}
              />
            </svg>
            <div className="absolute inset-0 flex items-center justify-center">
              <span className="text-sm font-semibold tabular-nums">
                <NumberTicker
                  value={Math.round(metric.value)}
                  static={isStatic}
                  delay={animate ? index * 0.09 : 0}
                />
              </span>
            </div>
          </div>
          <span className="flex items-center gap-1 text-xs text-muted-foreground transition-[color] duration-(--duration-fast) group-hover:text-foreground">
            <Icon aria-hidden className="size-3.5 shrink-0" />
            {metric.label}
          </span>
        </button>
      </TooltipTrigger>
      <TooltipContent>
        <div className="font-medium">
          {metric.label} · {Math.round(metric.value)}/100
        </div>
        <div className={cn("text-primary-foreground/70")}>
          {metric.detail ?? b.label}
        </div>
      </TooltipContent>
    </Tooltip>
  );
}

/**
 * Neighborhood livability scores (walk / transit / school) shown as radial
 * dials. Each ring is a full circle whose arc is computed from its
 * circumference (C = 2πr, offset = C·(1 − value/100)), so the sweep matches
 * the value exactly; the dials draw in on first view via stroke-dashoffset and
 * tint by band (success / warning / destructive). Centers count up on a
 * NumberTicker and each dial exposes a tooltip. Reduced motion (or `static`)
 * renders the final state immediately. Deterministic — geometry is derived.
 */
export function NeighborhoodScore({
  metrics = [],
  static: isStatic = false,
  className,
  ...props
}: NeighborhoodScoreProps) {
  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;

  const avg =
    metrics.length > 0
      ? metrics.reduce((s, m) => s + m.value, 0) / metrics.length
      : 0;
  const avgBand = band(avg);

  return (
    <TooltipProvider>
      <div
        ref={ref}
        data-slot="neighborhood-score"
        style={{
          opacity: armed ? 1 : 0,
          transform: armed ? "translateY(0)" : "translateY(12px)",
          filter: armed ? "blur(0)" : "blur(4px)",
          transition: animate
            ? "opacity 300ms var(--ease-out), transform 300ms var(--ease-out), filter 300ms var(--ease-out)"
            : undefined,
        }}
        className={cn(
          "w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div className="flex items-baseline justify-between gap-3">
          <p className="min-w-0 truncate text-sm font-medium">Neighborhood</p>
          {metrics.length > 0 && (
            <Tooltip>
              <TooltipTrigger asChild>
                <button
                  type="button"
                  aria-label={`Average livability ${Math.round(avg)} of 100`}
                  className="shrink-0 rounded-md text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  Avg{" "}
                  <span
                    className={cn("font-semibold tabular-nums", avgBand.text)}
                  >
                    <NumberTicker value={Math.round(avg)} static={isStatic} />
                  </span>
                  <span className="text-muted-foreground">/100</span>
                </button>
              </TooltipTrigger>
              <TooltipContent>{avgBand.label} livability</TooltipContent>
            </Tooltip>
          )}
        </div>

        {metrics.length > 0 ? (
          <div className="mt-4 flex items-start justify-around gap-1">
            {metrics.map((m, i) => (
              <Dial
                key={m.key}
                metric={m}
                animate={animate}
                armed={armed}
                isStatic={isStatic}
                index={i}
              />
            ))}
          </div>
        ) : (
          <div className="mt-4 flex flex-col items-center gap-2 rounded-lg border border-dashed border-border py-8 text-center">
            <MapPin aria-hidden className="size-5 text-muted-foreground" />
            <p className="text-sm text-muted-foreground">
              No livability data yet
            </p>
          </div>
        )}
      </div>
    </TooltipProvider>
  );
}