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}`,
];
}
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:
- Prefilling Make from WMI tables while the request is open
- Showing the previous VIN's card until the new one arrives
- Skeleton labels that include sample brand names ("Toyota...")
- Softening errors into empty catalog rows that look "not provided"
- 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",
};
});
}
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,
);
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)