Graphics

Road Signs

North American road sign set: highway guide, exit, next services, mile marker, speed limit and warning diamond on green, blue and yellow faces. Uses the Overpass sign font and Phosphor service icons.

Free, MIT licenseOpen in Browse
// Editorial road signs in the North American guide / service / regulatory /
// warning idiom: guide sign, exit, services, marker, speed limit and warning.

import {
  BedIcon,
  ForkKnifeIcon,
  GasPumpIcon,
} from "@phosphor-icons/react/dist/ssr"
import { cn } from "cn"

const FACE_GREEN = "bg-[oklch(0.47_0.092_171.22)]"
const FACE_BLUE = "bg-[oklch(0.323_0.103_253.89)]"
const FACE_YELLOW = "bg-[oklch(0.861_0.173_91.94)]"
const TEXT_GREEN = "text-[oklch(0.47_0.092_171.22)]"

// Secondary legend is a face-cast white, not a dimmed one — it recedes by hue.
// L=0.95 is the floor that clears 4.5:1 through the face wash; measured.
const LEGEND_GREEN = "text-[oklch(0.95_0.03_171)]"
const LEGEND_BLUE = "text-[oklch(0.88_0.035_254)]"

// Mounted sign under directional light: tight and hard, never a floating card.
const SIGN_SHADOW = "shadow-[0_8px_18px_oklch(0_0_0/0.22)]"

// The legend is the content, so a sign takes no children.
type SignProps = Omit<React.ComponentProps<"div">, "children">

type SignService = "gas" | "food" | "lodging"

type SignDestination = {
  name: string
  /** Shown on the right of the name, such as "47". */
  distance?: string
}

const SERVICE_GLYPHS = {
  gas: { Icon: GasPumpIcon, label: "Gas" },
  food: { Icon: ForkKnifeIcon, label: "Food" },
  lodging: { Icon: BedIcon, label: "Lodging" },
} as const

function ServiceRow({ services }: { services: SignService[] }) {
  if (services.length === 0) return null
  return (
    <div className="mt-2 flex gap-2">
      {services.map((service, index) => {
        const { Icon, label } = SERVICE_GLYPHS[service]
        return (
          <span
            key={`${service}-${index}`}
            className="grid size-6 place-items-center rounded-xs bg-[oklch(1_0_0)]"
          >
            <Icon
              weight="fill"
              className="size-4 text-[oklch(0_0_0)]"
              aria-hidden
            />
            <span className="sr-only">{label}</span>
          </span>
        )
      })}
    </div>
  )
}

/**
 * A green highway guide sign: an optional direction and route above a list
 * of destinations, each with an optional distance on the right. An exit
 * number, when given, sits on a tab over the top edge.
 */
function HighwaySign({
  className,
  route,
  destinations = [],
  exitNumber,
  direction,
  ...props
}: SignProps & {
  /** The route name, shown in a white box, such as "I-80". */
  route?: string
  /** The places listed on the sign, top to bottom. */
  destinations?: SignDestination[]
  /** Adds an "EXIT" tab with this number on the top edge, near the right. */
  exitNumber?: string
  /** Shown in capitals above the route. */
  direction?: "north" | "south" | "east" | "west"
}) {
  return (
    <div
      data-slot="highway-sign"
      className={cn(
        "relative rounded border-[3px] border-[oklch(1_0_0)] p-4 font-sign font-bold text-[oklch(1_0_0)]",
        // A restrained face highlight so the sign is not digitally flat.
        "before:pointer-events-none before:absolute before:inset-0 before:rounded before:bg-linear-to-b before:from-[oklch(1_0_0)]/10 before:to-transparent before:content-['']",
        FACE_GREEN,
        SIGN_SHADOW,
        className
      )}
      {...props}
    >
      {exitNumber && (
        <div
          className={cn(
            "absolute -top-3 right-4 rounded-xs border-2 border-[oklch(1_0_0)] px-2 py-0.5 text-xs/5",
            FACE_GREEN
          )}
        >
          EXIT {exitNumber}
        </div>
      )}
      {direction && (
        <div className={cn("mb-1 text-xs/5 tracking-wider", LEGEND_GREEN)}>
          {direction.toUpperCase()}
        </div>
      )}
      {route && (
        <div className="mb-2 flex">
          <div
            className={cn(
              "rounded-xs bg-[oklch(1_0_0)] px-2 py-0.5 text-sm/6 font-extrabold",
              TEXT_GREEN
            )}
          >
            {route}
          </div>
        </div>
      )}
      <div className="flex flex-col gap-1">
        {destinations.map((destination, index) => (
          <div
            key={`${index}-${destination.name}`}
            className="flex items-center justify-between gap-8 text-lg/7"
          >
            <span>{destination.name}</span>
            {destination.distance && (
              <span className={cn("whitespace-nowrap", LEGEND_GREEN)}>
                {destination.distance}
              </span>
            )}
          </div>
        ))}
      </div>
    </div>
  )
}

/**
 * A green exit sign: a yellow "EXIT" tab on the top-right corner, the
 * destination, an optional distance, and an optional row of service icons.
 */
function ExitSign({
  className,
  exitNumber,
  destination,
  distance,
  services = [],
  ...props
}: SignProps & {
  /** The number on the yellow "EXIT" tab. */
  exitNumber: string
  /** The place the exit leads to, the sign's main line of text. */
  destination: string
  /** Shown under the destination, such as "1/2 MILE". */
  distance?: string
  /**
   * Icons in a row under the text: "gas", "food" or "lodging". Each has a
   * label for screen readers.
   */
  services?: SignService[]
}) {
  return (
    <div
      data-slot="exit-sign"
      className={cn(
        "relative rounded-[3px] border-2 border-[oklch(1_0_0)] p-3 font-sign font-bold text-[oklch(1_0_0)]",
        FACE_GREEN,
        className
      )}
      {...props}
    >
      <div
        className={cn(
          "absolute -top-2 -right-2 rounded-xs px-2 py-0.5 text-xs/5 font-extrabold text-[oklch(0_0_0)]",
          FACE_YELLOW
        )}
      >
        EXIT {exitNumber}
      </div>
      <div className="text-lg/7">{destination}</div>
      {distance && (
        <div className={cn("mt-1 text-sm/6", LEGEND_GREEN)}>{distance}</div>
      )}
      <ServiceRow services={services} />
    </div>
  )
}

/**
 * A blue services sign that reads "NEXT SERVICES", a large mile count and
 * "MILES", with an optional row of service icons at the bottom.
 */
function NextExitSign({
  className,
  miles,
  services = [],
  ...props
}: SignProps & {
  /** The distance to the next services, shown large above "MILES". */
  miles: number
  /**
   * Icons in a row at the bottom: "gas", "food" or "lodging". Each has a
   * label for screen readers.
   */
  services?: SignService[]
}) {
  return (
    <div
      data-slot="next-exit-sign"
      className={cn(
        "rounded-[3px] border-2 border-[oklch(1_0_0)] p-4 text-center font-sign font-bold text-[oklch(1_0_0)]",
        FACE_BLUE,
        className
      )}
      {...props}
    >
      <div className={cn("text-sm/6 tracking-wider", LEGEND_BLUE)}>NEXT</div>
      <div className={cn("mb-2 text-xs/5 tracking-wider", LEGEND_BLUE)}>
        SERVICES
      </div>
      <div className="text-4xl/12 font-extrabold">{miles}</div>
      <div className="text-sm/6 tracking-wider">MILES</div>
      <div className="flex justify-center">
        <ServiceRow services={services} />
      </div>
    </div>
  )
}

/**
 * A small green mile marker: an optional route name, the mile number and the
 * word "MILE".
 */
function MileMarker({
  className,
  mile,
  route,
  ...props
}: SignProps & {
  /** The mile number, shown large above the word "MILE". */
  mile: number
  /** Shown small above the mile number, such as "I-80". */
  route?: string
}) {
  return (
    <div
      data-slot="mile-marker"
      className={cn(
        "min-w-16 rounded-xs border border-[oklch(1_0_0)]/50 px-2 py-3 text-center font-sign font-bold text-[oklch(1_0_0)]",
        FACE_GREEN,
        className
      )}
      {...props}
    >
      {route && (
        <div className={cn("text-xs/4 tracking-wide", LEGEND_GREEN)}>
          {route}
        </div>
      )}
      <div className="text-xl/7">{mile}</div>
      <div className={cn("text-xs/4 tracking-wider", LEGEND_GREEN)}>MILE</div>
    </div>
  )
}

/** A white regulatory sign that reads "SPEED LIMIT" over a large number. */
function SpeedLimitSign({
  className,
  speed,
  ...props
}: SignProps & {
  /** The limit, shown large under "SPEED LIMIT". */
  speed: number
}) {
  return (
    <div
      data-slot="speed-limit-sign"
      className={cn(
        "w-fit min-w-20 rounded-[3px] border-2 border-[oklch(0_0_0)] bg-[oklch(1_0_0)] px-3 py-4 text-center font-sign text-[oklch(0_0_0)]",
        className
      )}
      {...props}
    >
      <div className="text-xs/4 font-bold tracking-wider">SPEED</div>
      <div className="text-xs/4 font-bold tracking-wider">LIMIT</div>
      <div className="mt-2 text-5xl/12 font-extrabold">{speed}</div>
    </div>
  )
}

/**
 * A yellow diamond warning sign with a short message, and an optional second
 * line under it.
 *
 * The element's box is the diamond's full footprint, so no corner hangs
 * outside it, and the text sits in the square inside the diamond, so it stays
 * on the yellow. Resize it with a `size-*` class: the diamond and the text
 * area scale with it, the type does not.
 */
function WarningSign({
  className,
  text,
  subtext,
  ...props
}: SignProps & {
  /** The main message, such as "CURVE". */
  text: string
  /** A smaller second line under the message, such as "AHEAD". */
  subtext?: string
}) {
  return (
    <div
      data-slot="warning-sign"
      className={cn("relative size-[11.3136rem] font-sign", className)}
      {...props}
    >
      <div
        className={cn(
          "absolute inset-[calc((100%-100%/1.4142)/2)] rotate-45 rounded-[3px] border-2 border-[oklch(0_0_0)]",
          FACE_YELLOW
        )}
      />
      <div className="absolute inset-0 m-auto flex size-1/2 flex-col items-center justify-center font-extrabold text-[oklch(0_0_0)]">
        <span className="px-2 text-center text-sm/5">{text}</span>
        {subtext && <span className="mt-1 text-xs/4">{subtext}</span>}
      </div>
    </div>
  )
}

export {
  ExitSign,
  HighwaySign,
  MileMarker,
  NextExitSign,
  SpeedLimitSign,
  WarningSign,
  type SignDestination,
  type SignService,
}

Installation

pnpm dlx shadcn add @01orbital/road-signs
npx shadcn add @01orbital/road-signs
yarn shadcn add @01orbital/road-signs
bunx --bun shadcn add @01orbital/road-signs

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/graphics/road-signs.tsx, as source you own and edit.

Usage

import {
  ExitSign,
  HighwaySign,
  MileMarker,
  NextExitSign,
  SpeedLimitSign,
  WarningSign,
} from "@/components/graphics/road-signs"

// Every word on the page. Change copy here, not in the markup.
const COPY = {
  route: "I-80",
  exit: "Riverside",
  distance: "1/2 MILE",
  warning: "CURVE",
  warning2: "AHEAD",
} as const

const DESTINATIONS = [
  { name: "Cheyenne", distance: "47" },
  { name: "Salt Lake City", distance: "432" },
] as const

// Night ground: the faces are cut for a dark page — on white they flatten
// into stickers.
export default function RoadSignsDemo() {
  return (
    <>
      <div className="flex max-w-4xl flex-wrap items-end justify-center gap-8">
        <HighwaySign
          direction="west"
          route={COPY.route}
          destinations={[...DESTINATIONS]}
        />
        <NextExitSign miles={47} services={["gas", "food"]} />
        <ExitSign
          exitNumber="82"
          destination={COPY.exit}
          distance={COPY.distance}
          services={["gas", "food", "lodging"]}
        />
        <SpeedLimitSign speed={65} />
        <WarningSign text={COPY.warning} subtext={COPY.warning2} />
        <MileMarker mile={214} route={COPY.route} />
      </div>
    </>
  )
}

Anatomy

Road Signs is built from 6 parts. In the example they nest like this:

HighwaySign
NextExitSign
ExitSign
SpeedLimitSign
WarningSign
MileMarker
  • HighwaySign: A green highway guide sign: an optional direction and route above a list of destinations, each with an optional distance on the right. An exit number, when given, sits on a tab over the top edge.
  • NextExitSign: A blue services sign that reads "NEXT SERVICES", a large mile count and "MILES", with an optional row of service icons at the bottom.
  • ExitSign: A green exit sign: a yellow "EXIT" tab on the top-right corner, the destination, an optional distance, and an optional row of service icons.
  • SpeedLimitSign: A white regulatory sign that reads "SPEED LIMIT" over a large number.
  • WarningSign: A yellow diamond warning sign with a short message, and an optional second line under it.
  • MileMarker: A small green mile marker: an optional route name, the mile number and the word "MILE".

API reference

HighwaySign

A green highway guide sign: an optional direction and route above a list of destinations, each with an optional distance on the right. An exit number, when given, sits on a tab over the top edge.

Prop

Type

It also accepts the HTML attributes of the element it renders.

NextExitSign

A blue services sign that reads "NEXT SERVICES", a large mile count and "MILES", with an optional row of service icons at the bottom.

Prop

Type

It also accepts the HTML attributes of the element it renders.

ExitSign

A green exit sign: a yellow "EXIT" tab on the top-right corner, the destination, an optional distance, and an optional row of service icons.

Prop

Type

It also accepts the HTML attributes of the element it renders.

SpeedLimitSign

A white regulatory sign that reads "SPEED LIMIT" over a large number.

Prop

Type

It also accepts the HTML attributes of the element it renders.

WarningSign

A yellow diamond warning sign with a short message, and an optional second line under it.

The element's box is the diamond's full footprint, so no corner hangs outside it, and the text sits in the square inside the diamond, so it stays on the yellow. Resize it with a size-* class: the diamond and the text area scale with it, the type does not.

Prop

Type

It also accepts the HTML attributes of the element it renders.

MileMarker

A small green mile marker: an optional route name, the mile number and the word "MILE".

Prop

Type

It also accepts the HTML attributes of the element it renders.

Dependencies

npm: @phosphor-icons/react, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • font-overpass