Skip to content
Index

Tebak Plat: license plate guesser

Guess the country from the plate's color, size, and lettering.

Try Now (opens in a new tab)
Loading playground…
"use client";// Mirrored from tebak-plat plate.tsx on main (6ae6b708).// Upstream tilt uses motion/react. This file uses framer-motion, already in the portfolio.// Re-sync notes: SYNC.md. Japan, US, and Singapore layouts are portfolio-only.import { useEffect, useId, useRef, useSyncExternalStore, type PointerEvent, type SVGProps } from "react";import { useMotionValueEvent, useReducedMotion, useSpring } from "framer-motion";import localFont from "next/font/local";import type { WorldPlate } from "./types";import { plateDesign, type PlateModel } from "./plate-designs";import { plateGeometry, type PlateDimensions } from "./plate-geometry";import { plateLettering } from "./plate-typography";type PlateProps = Omit<SVGProps<SVGSVGElement>, "children"> & {  text: string;  world?: WorldPlate;  model?: PlateModel;  validity?: string | null;  dimensions?: PlateDimensions;  backdrop?: boolean;  debug?: boolean;  tilt?: boolean;};const spring = { duration: 0.4, bounce: 0 };const light = "oklch(1 0 0)";const dark = "oklch(0 0 0)";const euBlue = "oklch(0.34 0.16 263)";const euGold = "oklch(0.87 0.17 90)";const namedPaint: Record<string, string> = {  yellow: "oklch(0.9 0.15 98)",  white: "oklch(0.975 0.006 95)",  black: "oklch(0.18 0.002 95)",};function paint(color: string) {  return namedPaint[color] ?? color;}function faceWord(face: string) {  if (face === "yellow") return "yellow";  if (face.includes(" 85)")) return "gold";  if (face === "white" || face.startsWith("oklch(0.9") || face.startsWith("oklch(0.97")) return "white";  if (face === "black" || face.startsWith("oklch(0.21")) return "black";  return "custom";}function inkWord(ink: string) {  if (ink === "black" || ink.startsWith("oklch(0.18") || ink.startsWith("oklch(0.21")) return "black";  if (ink === "white" || ink.startsWith("oklch(0.95") || ink.startsWith("oklch(0.97")) return "white";  if (ink === "#8b1b32" || ink.includes("0.15 25")) return "red";  if (ink.includes("155")) return "green";  if (ink.includes("255") || ink.includes("260")) return "blue";  return "dark";}const condensed = localFont({  src: "../../../fonts/plate/D-DINCondensed-Bold.woff2",  weight: "700",  display: "swap",  adjustFontFallback: "Arial",  preload: false,});const regular = localFont({  src: "../../../fonts/plate/D-DINCondensed.woff2",  weight: "400",  display: "swap",  adjustFontFallback: "Arial",  preload: false,});const din = localFont({  src: "../../../fonts/plate/D-DIN.woff2",  weight: "400",  display: "swap",  adjustFontFallback: "Arial",  preload: false,});const faces = { condensed, regular, din };const debugRequested =  typeof window !== "undefined" && new URLSearchParams(window.location.search).get("debug") === "1";const subscribeDebug = () => () => {};function Lettering({  text,  face,  height,  width,  x,  y,  filter,  row,}: {  text: string;  face: "condensed" | "regular" | "din";  height: number;  width: number;  x: number;  y: number;  filter?: string;  row?: string;}) {  const run = plateLettering(text, face, height, width);  const fontSize = height / 0.69;  return (    <text      className={faces[face].className}      x={x}      y={y}      data-plate-row={row}      textAnchor="middle"      dominantBaseline="central"      dy={fontSize * 0.014}      fontSize={fontSize}      fontWeight={face === "condensed" ? 700 : 400}      textLength={run.width * run.fit}      lengthAdjust="spacingAndGlyphs"      letterSpacing="2.2"      filter={filter}      style={row === "registration" ? { fontVariantNumeric: "tabular-nums" } : undefined}    >      {text}    </text>  );}export function Plate({  text,  world,  model = "private",  validity = "10 · 29",  dimensions,  backdrop = false,  debug,  tilt = true,  style,  className,  ref,  ...props}: PlateProps) {  const id = useId().replace(/[^a-zA-Z0-9_-]/g, "");  const svg = useRef<SVGSVGElement | null>(null);  const sheen = useRef<SVGGElement>(null);  const bounds = useRef<DOMRect | null>(null);  const reduced = useReducedMotion();  const pitch = useSpring(0, spring);  const yaw = useSpring(0, spring);  const design = plateDesign(world, model);  const geometry = plateGeometry(world, dimensions);  const showDebug = useSyncExternalStore(subscribeDebug, () => debugRequested, () => false) || debug;  const moving = tilt && !reduced;  function paintTilt() {    if (!svg.current) return;    svg.current.style.transform = moving      ? `perspective(800px) rotateX(${pitch.get()}deg) rotateY(${yaw.get()}deg)`      : "none";    if (sheen.current) sheen.current.style.transform = moving ? `translateX(${yaw.get() * 12}px)` : "none";    if (pitch.get() === 0 && yaw.get() === 0) svg.current.style.willChange = "auto";  }  useMotionValueEvent(pitch, "change", paintTilt);  useMotionValueEvent(yaw, "change", paintTilt);  useEffect(() => {    if (!moving) {      pitch.jump(0);      yaw.jump(0);    }  }, [moving, pitch, yaw]);  function track(event: PointerEvent<SVGSVGElement>) {    if (!moving || event.pointerType !== "mouse" || !bounds.current) return;    const box = bounds.current;    let x = Math.max(-1, Math.min(1, ((event.clientX - box.left) / box.width) * 2 - 1));    let y = Math.max(-1, Math.min(1, ((event.clientY - box.top) / box.height) * 2 - 1));    const length = Math.max(1, Math.hypot(x, y));    x /= length;    y /= length;    pitch.set(-y * 4);    yaw.set(x * 4);  }  if (!design) return null;  const { width: w, height: h, face, rim, faceInset, bandWidth, rightBandWidth } = geometry;  const legacy = !world && model === "legacy";  const showValidity = !world && validity !== null;  const capHeight = (design.capHeight / design.height) * h;  const mainY = showValidity    ? face.top + (face.bottom - face.top) * 0.45    : design.caption      ? (face.top + 18 + face.bottom) / 2      : face.cy;  const validityY = (mainY + capHeight / 2 + face.bottom) / 2;  const rivetY = (face.top + mainY - capHeight / 2) / 2;  const rivetRadius = Math.min(3.1, (mainY - capHeight / 2 - face.top) / 4);  const center = face.cx;  const overhang = geometry.backdrop.overhang;  const angle = (Math.abs(geometry.backdrop.rotation) * Math.PI) / 180;  const backdropScale = Math.max(    ((w + 2 * overhang) * Math.cos(angle) + (h + 2 * overhang) * Math.sin(angle)) / w,    ((h + 2 * overhang) * Math.cos(angle) + (w + 2 * overhang) * Math.sin(angle)) / h,  );  const emboss = `url(#${id}-emboss)`;  const faceName = faceWord(design.face);  const inkName = inkWord(design.ink);  const label = `Plate ${text}. ${    world      ? `${faceName[0]!.toUpperCase()}${faceName.slice(1)} background, ${inkName} text${design.band ? `, blue band on the left reading ${design.band}` : ""}.`      : `Indonesian ${legacy ? "black" : "white"} plate, ${legacy ? "white" : "black"} text, valid through ${validity}.`  }`;  return (    <svg      {...props}      ref={(node) => {        svg.current = node;        if (typeof ref === "function") ref(node);        else if (ref) ref.current = node;      }}      className={className}      width={w}      height={h}      viewBox={`0 0 ${w} ${h}`}      role="img"      aria-label={props["aria-label"] ?? label}      data-plate-model={world?.id ?? model}      data-plate-text={text}      data-plate-font={design.fontFamily}      onPointerEnter={(event) => {        if (          moving &&          event.pointerType === "mouse" &&          window.matchMedia("(hover: hover) and (pointer: fine)").matches        ) {          bounds.current = event.currentTarget.getBoundingClientRect();          event.currentTarget.style.willChange = "transform";        }        props.onPointerEnter?.(event);      }}      onPointerMove={(event) => {        track(event);        props.onPointerMove?.(event);      }}      onPointerLeave={(event) => {        bounds.current = null;        pitch.set(0);        yaw.set(0);        props.onPointerLeave?.(event);      }}      style={{        display: "block",        width: "100%",        height: "auto",        aspectRatio: `${w} / ${h}`,        overflow: "visible",        userSelect: "none",        transformOrigin: "50% 50%",        ...style,      }}    >      <defs>        <rect id={`${id}-blank`} x="0.4" y="0.4" width={w - 0.8} height={h - 0.8} rx={geometry.outerRadius - 0.4} />        <clipPath id={`${id}-face-clip`}>          <rect            x={faceInset}            y={faceInset}            width={w - faceInset * 2}            height={h - faceInset * 2}            rx={rim.rx - geometry.stroke / 2}          />        </clipPath>        <clipPath id={`${id}-backdrop-clip`}>          <rect            x={-overhang}            y={-overhang}            width={w + overhang * 2}            height={h + overhang * 2}            rx={geometry.outerRadius + overhang}          />        </clipPath>        <pattern id={`${id}-grid`} width="10" height="10" patternUnits="userSpaceOnUse">          <path d="M10 0H0V10" fill="none" stroke={euBlue} strokeOpacity="0.25" strokeWidth="1" vectorEffect="non-scaling-stroke" />        </pattern>        <clipPath id={`${id}-clip`}>          <use href={`#${id}-blank`} />        </clipPath>        <linearGradient id={`${id}-face`} x1="0" y1="0" x2="0.25" y2="1">          <stop stopColor={light} stopOpacity={legacy ? 0.08 : 0.34} />          <stop offset="0.23" stopColor={light} stopOpacity="0.02" />          <stop offset="0.52" stopColor={dark} stopOpacity="0.035" />          <stop offset="0.78" stopColor={light} stopOpacity="0.1" />          <stop offset="1" stopColor={dark} stopOpacity="0.11" />        </linearGradient>        <linearGradient id={`${id}-rim`} x1="0.15" y1="0" x2="0.45" y2="1">          <stop stopColor={light} stopOpacity="0.85" />          <stop offset="0.16" stopColor={light} stopOpacity="0.28" />          <stop offset="0.42" stopColor={dark} stopOpacity="0.12" />          <stop offset="0.75" stopColor={dark} stopOpacity="0.43" />          <stop offset="1" stopColor={light} stopOpacity="0.66" />        </linearGradient>        <linearGradient id={`${id}-sheen`} x1="0" y1="0" x2="1" y2="0">          <stop stopColor={light} stopOpacity="0" />          <stop offset="0.3" stopColor={light} stopOpacity="0" />          <stop offset="0.48" stopColor={light} stopOpacity="0.28" />          <stop offset="0.6" stopColor={light} stopOpacity="0.035" />          <stop offset="1" stopColor={light} stopOpacity="0" />        </linearGradient>        <filter id={`${id}-brush`} filterUnits="userSpaceOnUse" x="0" y="0" width={w} height={h} colorInterpolationFilters="sRGB">          <feTurbulence type="fractalNoise" baseFrequency="0.025 0.85" numOctaves="2" seed="17" stitchTiles="stitch" />          <feColorMatrix type="saturate" values="0" />          <feComposite in2="SourceAlpha" operator="in" />        </filter>        <filter id={`${id}-emboss`} filterUnits="userSpaceOnUse" x="0" y="0" width={w} height={h} colorInterpolationFilters="sRGB">          <feGaussianBlur in="SourceAlpha" stdDeviation="0.45" result="height" />          <feDiffuseLighting in="height" surfaceScale="1.4" diffuseConstant="0.68" lightingColor={light} result="diffuse">            <feDistantLight azimuth="225" elevation="52" />          </feDiffuseLighting>          <feComposite in="diffuse" in2="SourceGraphic" operator="arithmetic" k1="0.45" k2="0" k3="0.7" k4="0" result="body" />          <feSpecularLighting in="height" surfaceScale="2.1" specularConstant="0.55" specularExponent="20" lightingColor={light} result="specular">            <feDistantLight azimuth="225" elevation="48" />          </feSpecularLighting>          <feComposite in="specular" in2="SourceAlpha" operator="in" result="crest" />          <feOffset in="SourceAlpha" dx="1" dy="1" result="offset" />          <feComposite in="offset" in2="SourceAlpha" operator="out" result="lowerEdge" />          <feFlood floodColor={dark} floodOpacity="0.65" />          <feComposite in2="lowerEdge" operator="in" result="edge" />          <feMerge>            <feMergeNode in="edge" />            <feMergeNode in="body" />            <feMergeNode in="crest" />          </feMerge>        </filter>      </defs>      <g aria-hidden="true">        {backdrop ? (          <g clipPath={`url(#${id}-backdrop-clip)`} data-plate-backdrop-window="3">            <g              transform={`translate(${w / 2} ${h / 2}) rotate(${geometry.backdrop.rotation}) scale(${backdropScale}) translate(${-w / 2} ${-h / 2})`}            >              <rect                data-plate-backdrop="true"                x="0"                y="0"                width={w}                height={h}                rx={geometry.outerRadius}                fill="color-mix(in oklab, var(--home-ink) 12%, var(--home-bg))"              />            </g>          </g>        ) : null}        <use href={`#${id}-blank`} fill={paint(design.face)} stroke={dark} strokeOpacity="0.45" strokeWidth="0.8" />        <use href={`#${id}-blank`} fill={`url(#${id}-face)`} />        <g clipPath={`url(#${id}-face-clip)`}>          <rect width={w} height={h} fill={paint(design.face)} filter={`url(#${id}-brush)`} opacity={legacy ? 0.025 : 0.042} />          {design.band ? (            <>              <rect data-plate-band="left" x={faceInset} y={faceInset} width={bandWidth} height={h - faceInset * 2} fill={euBlue} />              {design.eu                ? Array.from({ length: 12 }, (_, i) => {                    const star = ((i * 30 - 90) * Math.PI) / 180;                    return (                      <path                        key={i}                        fill={euGold}                        transform={`translate(${faceInset + bandWidth / 2 + Math.cos(star) * 10} ${face.top + (h - faceInset * 2) * 0.27 + Math.sin(star) * 10})`}                        d="M0-2.2 .52-.7 2.1-.68 .84.3 1.3 1.8 0 .88-1.3 1.8-.84.3-2.1-.68-.52-.7Z"                      />                    );                  })                : null}              <g fill={light}>                <Lettering                  text={design.band}                  face="din"                  height={design.band.length > 2 ? 11 : 14}                  width={28}                  x={faceInset + bandWidth / 2}                  y={face.top + (h - faceInset * 2) * 0.75}                />              </g>            </>          ) : null}          {design.rightBand !== undefined ? (            <>              <rect                data-plate-band="right"                x={w - faceInset - rightBandWidth}                y={faceInset}                width={rightBandWidth}                height={h - faceInset * 2}                fill={euBlue}              />              <g fill={light}>                <Lettering                  text={design.rightBand}                  face="din"                  height={13}                  width={26}                  x={w - faceInset - rightBandWidth / 2}                  y={face.top + (h - faceInset * 2) * 0.75}                />              </g>            </>          ) : null}          {design.stripes ? (            <g fill="oklch(0.47 0.18 25)" opacity="0.8">              <rect x={face.left} y={face.top} width={face.right - face.left} height="1.7" />              <rect x={face.left} y={face.bottom - 1.7} width={face.right - face.left} height="1.7" />            </g>          ) : null}          <g ref={sheen} style={{ pointerEvents: "none" }}>            <rect x="-80" width={w + 160} height={h} fill={`url(#${id}-sheen)`} />          </g>        </g>        <rect          data-plate-rim="true"          x={rim.x}          y={rim.y}          width={rim.width}          height={rim.height}          rx={rim.rx}          fill="none"          stroke={design.outline ?? `url(#${id}-rim)`}          strokeWidth={geometry.stroke}        />        {!world          ? [w * 0.2, w * 0.8].map((x) => (              <g key={x}>                <circle data-plate-rivet="true" cx={x} cy={rivetY} r={rivetRadius} fill={dark} opacity="0.48" />                <path                  d={`M${x - rivetRadius} ${rivetY}a${rivetRadius} ${rivetRadius} 0 0 0 ${rivetRadius * 2} 0`}                  fill="none"                  stroke={light}                  strokeOpacity="0.78"                  strokeWidth="0.8"                />              </g>            ))          : null}        <g fill={paint(design.ink)}>          <Lettering            text={text}            face={design.glyphs}            height={capHeight}            width={face.right - face.left - 56}            x={center}            y={mainY}            filter={emboss}            row="registration"          />          {showValidity ? (            <Lettering text={validity!} face="regular" height={17} width={90} x={center} y={validityY} filter={emboss} row="validity" />          ) : null}          {design.caption ? (            <Lettering text={design.caption} face="din" height={8.5} width={240} x={center} y={face.top + 8} />          ) : null}        </g>        {showDebug ? (          <g data-plate-debug="true" pointerEvents="none">            <rect width={w} height={h} fill={`url(#${id}-grid)`} />            <rect x={rim.x} y={rim.y} width={rim.width} height={rim.height} rx={rim.rx} fill="none" stroke={euBlue} strokeWidth="1" vectorEffect="non-scaling-stroke" />            <path d={`M${w / 2} 0V${h}M0 ${h / 2}H${w}`} stroke={euBlue} strokeWidth="1" vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />          </g>        ) : null}      </g>    </svg>  );}

Build it yourself

This prompt has the plate files and a usage example. Paste it into a chat to get the same plate.

Why

The number is the easy read. The plate is what you guess from. A long white Indonesian plate and a yellow UK plate do not look like the same object. I draw them here as SVG, so the letters are text.

Size

Indonesia is 430×135. Private plates are white with black text. The older plates are black with white text. Germany's ratio is 4.73, with a blue band on the left. The UK rear plate is yellow and has no band. Japan is 330×165. California, New York, and Texas are 12 by 6 inches. Singapore is 370×110.

Indonesia 430×135
Germany ratio 4.73
California 12×6 in

Shuffle

Shuffle rolls a new country and a new number. A country chip stays in that country and rolls a new region and number. The old plate moves up and fades out. The new one fades in from 8px below. The swap is 220ms, ease-out, and it only animates transform and opacity.

duration: 0.22
ease: [0.32, 0.72, 0, 1]
transform + opacity

Reduced motion

With reduced motion on, that swap is a 150ms crossfade. The plate does not travel, and the pointer tilt stays off.

reduce ? { opacity: 0 } : { opacity: 0, y: 8 }

Streak

The digit rolls in 150 ms. The slot stays two characters wide, so the line beside it does not shift.

0

Mode switch

One thumb moves in 220 ms. The labels keep the same weight.

Guessing the region.

Result

The round ends on a score, a dot for each plate, and two streak numbers.

Indonesia · Daily, 13 Oct

8 of 10 correct.Close. A couple of plates still look alike.

Best streak
3
Record
6

Pressed plate

Move the pointer across the plate. Tilt stops at 4°. Reduced motion keeps the light still. The menu switches the model.