DEV Community

Vin Lookup
Vin Lookup

Posted on

Skeleton Loading States for VIN Decode Cards Without Fake Partial Results

A free VIN decode card should feel fast: the user pastes a VIN, you call NHTSA vPIC, and results appear. Between submit and response, the UI needs a loading state. The trap is showing skeleton shapes that look like real Make, Model, or Year -- or worse, flashing invented partial results while the request is still in flight.

This post is about honest loading UX: clear empty vs loading vs error, skeletons that never impersonate catalog values, and a ban on partial inventing even when it "looks smoother." A pulsing grey box is fine; a grey "Toyota" that was never returned is not.

What a skeleton is (and is not)

A skeleton loading state answers a narrow question: is the decode request still in flight?

It does not answer:

  • What Make, Model, or ModelYear the VIN will decode to
  • Whether neighboring ADAS or engine fields will be populated
  • A "best guess" from VIN position heuristics you did not confirm with vPIC
  • An optimistic card that pretends the last successful decode still applies
  • A soft error that looks like empty catalog data

Empty before submit is not loading. Loading is not error. Error is not "not provided by vPIC." Mixing those states trains users to distrust blanks.

Three states, never a fourth invent

Model the card as an explicit state machine: idle, loading, ready, and error stay distinct.

const EMPTY = new Set([
  "",
  "not applicable",
  "n/a",
  "na",
  "null",
  "none",
  "unknown",
]);

export type DecodePhase =
  | { kind: "idle" }
  | { kind: "loading"; vin: string }
  | { kind: "ready"; fields: Record<string, string | null> }
  | { kind: "error"; message: string };

export type FieldView = {
  value: string | null;
  source: "vpic" | "missing";
};

export function viewField(
  fields: Record<string, string | null>,
  key: string,
): FieldView {
  const raw = (fields[key] ?? "").trim();
  if (!raw || EMPTY.has(raw.toLowerCase())) {
    return { value: null, source: "missing" };
  }
  return { value: raw, source: "vpic" };
}

export function cardCopy(phase: DecodePhase): string[] {
  if (phase.kind === "idle") {
    return ["Enter a VIN to decode. No results yet."];
  }
  if (phase.kind === "loading") {
    return [
      `Decoding ${phase.vin}...`,
      "Results appear only after vPIC responds -- no partial invent",
    ];
  }
  if (phase.kind === "error") {
    return [`Decode failed: ${phase.message}`];
  }
  const make = viewField(phase.fields, "Make");
  const model = viewField(phase.fields, "Model");
  const year = viewField(phase.fields, "ModelYear");
  return [
    make.source === "missing"
      ? "Make: not provided by vPIC for this VIN"
      : `Make (vPIC): ${make.value}`,
    model.source === "missing"
      ? "Model: not provided by vPIC for this VIN"
      : `Model (vPIC): ${model.value}`,
    year.source === "missing"
      ? "Model year: not provided by vPIC for this VIN"
      : `Model year (vPIC): ${year.value}`,
  ];
}
Enter fullscreen mode Exit fullscreen mode

Loading never emits Make/Model/Year. Idle never looks like failure. Ready never paints loading chrome over real rows.

Forbidden optimistic invents

Product pressure often asks for:

  1. Prefilling Make from WMI tables while the request is open
  2. Showing the previous VIN's card until the new one arrives
  3. Skeleton labels that include sample brand names ("Toyota...")
  4. Softening errors into empty catalog rows that look "not provided"
  5. Animating fake field values that morph into real ones after paint

Refuse those. Skeletons should be shapes and aria-busy hints -- not catalog impersonators. Cache a prior decode only if labeled prior and hidden when a new request starts.

export function assertNoFakePartial(moduleSource: string): void {
  const banned = [
    /while loading.*make/i,
    /optimistic make/i,
    /guess from wmi/i,
    /previous vin card/i,
    /sample toyota/i,
    /fake partial/i,
  ];
  for (const re of banned) {
    if (re.test(moduleSource)) {
      throw new Error(
        `skeleton module must not invent partial results: ${re}`,
      );
    }
  }
}

export type SkeletonRow = {
  label: string;
  mode: "pulse" | "value" | "missing";
};

export function skeletonRows(phase: DecodePhase): SkeletonRow[] {
  if (phase.kind === "loading") {
    return [
      { label: "Make", mode: "pulse" },
      { label: "Model", mode: "pulse" },
      { label: "Model year", mode: "pulse" },
    ];
  }
  if (phase.kind !== "ready") return [];
  return (["Make", "Model", "ModelYear"] as const).map((key) => {
    const v = viewField(phase.fields, key);
    return {
      label: key === "ModelYear" ? "Model year" : key,
      mode: v.source === "missing" ? "missing" : "value",
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

Pulse rows carry labels only -- never brand placeholders. Missing after ready is "not provided," not a leftover skeleton.

UI copy that stays honest

Prefer:

  • "Decoding VIN..." with neutral pulse bars
  • "Make: not provided by vPIC for this VIN" after a real empty field
  • "Decode failed: ..." with a retry that clears invent

Avoid:

  • Grey text that reads like "Honda Civic 2019" before the response
  • Keeping the last car's card visible without a "previous decode" label
  • Treating network failure as if every field were "not provided"

Quick checks

import assert from "node:assert/strict";

assert.ok(
  cardCopy({ kind: "idle" }).some((l) => /no results yet/i.test(l)),
);
assert.ok(
  !cardCopy({ kind: "loading", vin: "1HGCM82633A004352" }).some((l) =>
    /\(vPIC\):/i.test(l),
  ),
);

const ready = cardCopy({
  kind: "ready",
  fields: { Make: "", Model: "Civic", ModelYear: "2019" },
});
assert.ok(ready.some((l) => /Make: not provided/i.test(l)));
assert.ok(ready.some((l) => /Model \(vPIC\): Civic/i.test(l)));
assert.ok(
  !skeletonRows({ kind: "loading", vin: "X" }).some(
    (r) => r.mode === "value",
  ),
);
assert.equal(
  skeletonRows({
    kind: "ready",
    fields: { Make: "Honda", Model: "Civic", ModelYear: "2019" },
  }).every((r) => r.mode === "value"),
  true,
);
Enter fullscreen mode Exit fullscreen mode

Review rule: skeleton modules must not contain optimistic brand strings except in forbidding tests.

Takeaway

Skeleton loading is a phase, not a preview of catalog truth. Keep idle, loading, ready, and error distinct; pulse without fake partials; and only print Make, Model, or Year after vPIC responds -- including honest "not provided" when empty. Your free VIN UI stays trustworthy when loading chrome never impersonates a decode.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)