Tawny

Maps

Route sheet

A timeline for bike, drive, and transit. One accent, a hairline border, and a 4px grid.

components/route-sheet

Route

1 hr

10 min

Start

Your Location

34 min

Pickup

W 84 St & Amsterdam Ave

Bikes

30

EBikes

25

44 min

Dropoff

Barrow St & Hudson St

Parking

41

Destination

Wall Street

Source

Copy the files below into your project.

apps/web/components/landing/showcases/route-sheet-demo.tsx
'use client'

import { useState } from 'react'
import {
  Bell,
  Bike,
  Car,
  CircleParking,
  KeyRound,
  Lock,
  LockOpen,
  Navigation,
  PersonStanding,
  TrainFront,
  X,
  Zap,
} from 'lucide-react'
import { ScaledDesignCanvas } from '@/components/landing/scaled-design-canvas'
import { cn } from '@tawny/ui/lib/utils'

type RouteId = 'bike' | 'drive' | 'transit'
type LegMode = 'walk' | 'bike' | 'drive' | 'transit'
type StopKind = 'start' | 'stop' | 'end'
type StopIconName = 'navigate' | 'unlock' | 'lock' | 'key' | 'parking' | 'station'
type StatIconName = 'bike' | 'ebike' | 'parking' | 'train'

type Stat = {
  label: string
  value: string
  icon: StatIconName
}

type Stop = {
  kicker: string
  title: string
  kind: StopKind
  icon: StopIconName
  stats?: Stat[]
}

type Leg = {
  mode: LegMode
  minutes: number
  via?: string
}

type Route = {
  id: RouteId
  label: string
  duration: string
  stops: Stop[]
  legs: Leg[]
}

const ROUTES: Route[] = [
  {
    id: 'bike',
    label: 'Bike',
    duration: '1 hr',
    stops: [
      { kicker: 'Start', title: 'Your Location', kind: 'start', icon: 'navigate' },
      {
        kicker: 'Pickup',
        title: 'W 84 St & Amsterdam Ave',
        kind: 'stop',
        icon: 'unlock',
        stats: [
          { label: 'Bikes', value: '30', icon: 'bike' },
          { label: 'EBikes', value: '25', icon: 'ebike' },
        ],
      },
      {
        kicker: 'Dropoff',
        title: 'Barrow St & Hudson St',
        kind: 'stop',
        icon: 'lock',
        stats: [{ label: 'Parking', value: '41', icon: 'parking' }],
      },
      { kicker: 'Destination', title: 'Wall Street', kind: 'end', icon: 'key' },
    ],
    legs: [
      { mode: 'walk', minutes: 10 },
      { mode: 'bike', minutes: 34 },
      { mode: 'walk', minutes: 44 },
    ],
  },
  {
    id: 'drive',
    label: 'Drive',
    duration: '22 min',
    stops: [
      { kicker: 'Start', title: 'Your Location', kind: 'start', icon: 'navigate' },
      {
        kicker: 'Park',
        title: 'Battery Parking Garage',
        kind: 'stop',
        icon: 'parking',
        stats: [{ label: 'Parking', value: '64', icon: 'parking' }],
      },
      { kicker: 'Destination', title: 'Wall Street', kind: 'end', icon: 'key' },
    ],
    legs: [
      { mode: 'drive', minutes: 18, via: 'West Side Highway' },
      { mode: 'walk', minutes: 4 },
    ],
  },
  {
    id: 'transit',
    label: 'Transit',
    duration: '32 min',
    stops: [
      { kicker: 'Start', title: 'Your Location', kind: 'start', icon: 'navigate' },
      {
        kicker: 'Station',
        title: '86 St',
        kind: 'stop',
        icon: 'station',
        stats: [{ label: 'Line', value: '1', icon: 'train' }],
      },
      { kicker: 'Station', title: 'Wall St', kind: 'stop', icon: 'station' },
      { kicker: 'Destination', title: 'Wall Street', kind: 'end', icon: 'key' },
    ],
    legs: [
      { mode: 'walk', minutes: 4 },
      { mode: 'transit', minutes: 26 },
      { mode: 'walk', minutes: 2 },
    ],
  },
]

const ICON_BUTTON =
  'inline-flex size-8 items-center justify-center rounded-full transition-colors duration-[80ms] ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tawny'

export function RouteSheet({ route }: { route: Route }) {
  return (
    <section
      aria-label={`${route.label} route`}
      className="w-full max-w-sm rounded-2xl border border-border bg-card p-4 tracking-[-0.01em]"
    >
      <div className="grid grid-cols-[2rem_1fr_2rem] items-center gap-2">
        <button type="button" aria-label="Alerts" className={cn(ICON_BUTTON, 'bg-tawny text-tawny-foreground hover:bg-tawny/80')}>
          <Bell className="size-4" aria-hidden="true" />
        </button>
        <div className="text-center">
          <h2 className="text-[15px] font-semibold leading-5 text-foreground">Route</h2>
          <p className="text-[13px] leading-4 text-muted-foreground tabular-nums">{route.duration}</p>
        </div>
        <button
          type="button"
          aria-label="Close route"
          className={cn(ICON_BUTTON, 'border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground')}
        >
          <X className="size-4" aria-hidden="true" />
        </button>
      </div>

      <div className="mt-4">
        {route.stops.map((stop, index) => (
          <TimelineStop key={`${stop.kicker}-${stop.title}-${index}`} stop={stop} leg={route.legs[index]} />
        ))}
      </div>
    </section>
  )
}

function TimelineStop({ stop, leg }: { stop: Stop; leg?: Leg }) {
  return (
    <div className="relative grid grid-cols-[4.5rem_2rem_minmax(0,1fr)] gap-x-2">
      {leg ? (
        <div className="pointer-events-none absolute top-4 -bottom-4 left-24 w-4 -translate-x-1/2">
          <Spine mode={leg.mode} />
        </div>
      ) : null}

      {leg ? (
        <div className="absolute top-4 -bottom-4 left-0 flex w-[4.5rem] flex-col items-center justify-center gap-1">
          <ModeIcon mode={leg.mode} />
          <span className="rounded-full border border-border bg-accent px-2 py-0.5 text-[11px] leading-4 text-muted-foreground tabular-nums">
            {leg.minutes} min
          </span>
        </div>
      ) : null}

      <div className="relative z-10 col-start-2 row-start-1 flex h-8 justify-center">
        <StopNode kind={stop.kind} icon={stop.icon} />
      </div>

      <div className="col-start-3 row-start-1 pb-1">
        <p className="text-[12px] leading-4 text-muted-foreground">{stop.kicker}</p>
        <p className="text-[15px] font-medium leading-5 text-foreground">{stop.title}</p>
        {stop.stats ? <Stats stats={stop.stats} /> : null}
      </div>

      {leg ? (
        <div className={cn('col-start-3 row-start-2 flex items-center', leg.mode === 'bike' ? 'min-h-16' : 'min-h-12')}>
          {leg.via ? <p className="text-[13px] leading-4 text-muted-foreground">via {leg.via}</p> : null}
        </div>
      ) : null}
    </div>
  )
}

function StopNode({ kind, icon }: { kind: StopKind; icon: StopIconName }) {
  const endpoint = kind === 'start' || kind === 'end'
  return (
    <span
      className={cn(
        'inline-flex size-8 items-center justify-center rounded-full',
        endpoint ? 'bg-tawny text-tawny-foreground' : 'border border-border bg-card text-muted-foreground',
      )}
    >
      <StopGlyph icon={icon} />
    </span>
  )
}

function StopGlyph({ icon }: { icon: StopIconName }) {
  const className = 'size-4'
  if (icon === 'navigate') return <Navigation className={className} aria-hidden="true" />
  if (icon === 'unlock') return <LockOpen className={className} aria-hidden="true" />
  if (icon === 'lock') return <Lock className={className} aria-hidden="true" />
  if (icon === 'key') return <KeyRound className={className} aria-hidden="true" />
  if (icon === 'parking') return <CircleParking className={className} aria-hidden="true" />
  return <TrainFront className={className} aria-hidden="true" />
}

function ModeIcon({ mode }: { mode: LegMode }) {
  const className = 'size-4 text-muted-foreground'
  if (mode === 'walk') return <PersonStanding className={className} aria-hidden="true" />
  if (mode === 'bike') return <Bike className={className} aria-hidden="true" />
  if (mode === 'drive') return <Car className={className} aria-hidden="true" />
  return <TrainFront className={className} aria-hidden="true" />
}

function Stats({ stats }: { stats: Stat[] }) {
  return (
    <div className="mt-2 flex gap-6">
      {stats.map((stat) => (
        <div key={stat.label}>
          <p className="text-[11px] leading-4 text-muted-foreground">{stat.label}</p>
          <p className="mt-1 flex items-center gap-1.5 text-[13px] leading-4 text-foreground tabular-nums">
            <StatGlyph icon={stat.icon} />
            {stat.value}
          </p>
        </div>
      ))}
    </div>
  )
}

function StatGlyph({ icon }: { icon: StatIconName }) {
  const className = 'size-4 text-muted-foreground'
  if (icon === 'bike') return <Bike className={className} aria-hidden="true" />
  if (icon === 'ebike') return <Zap className={className} aria-hidden="true" />
  if (icon === 'parking') return <CircleParking className={className} aria-hidden="true" />
  return <TrainFront className={className} aria-hidden="true" />
}

function Spine({ mode }: { mode: LegMode }) {
  if (mode === 'bike') {
    return (
      <svg className="h-full w-4 text-muted-foreground" viewBox="0 0 16 80" preserveAspectRatio="none" aria-hidden="true">
        <path
          d="M8 0C12 10 12 10 8 20C4 30 4 30 8 40C12 50 12 50 8 60C4 70 4 70 8 80"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          vectorEffect="non-scaling-stroke"
        />
      </svg>
    )
  }

  if (mode === 'transit') {
    return (
      <div className="flex h-full w-4 justify-center">
        <div
          className="h-full w-px"
          style={{
            backgroundImage: 'linear-gradient(to bottom, var(--border) 4px, transparent 4px)',
            backgroundSize: '1px 8px',
          }}
        />
      </div>
    )
  }

  return (
    <div className="flex h-full w-4 justify-center">
      <div className="h-full w-px bg-border" />
    </div>
  )
}

function RouteSwitcher({
  value,
  onChange,
}: {
  value: RouteId
  onChange: (id: RouteId) => void
}) {
  return (
    <div role="tablist" aria-label="Ways to get there" className="mb-3 flex flex-wrap justify-center gap-2">
      {ROUTES.map((route) => {
        const selected = route.id === value
        return (
          <button
            key={route.id}
            type="button"
            role="tab"
            aria-selected={selected}
            onClick={() => onChange(route.id)}
            className={cn(
              'rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors duration-[80ms] ease-out',
              selected
                ? 'bg-foreground text-background'
                : 'border border-border text-muted-foreground hover:text-foreground',
            )}
          >
            {route.label}
          </button>
        )
      })}
    </div>
  )
}

function RouteSheetStage() {
  const [id, setId] = useState<RouteId>('bike')
  const route = ROUTES.find((entry) => entry.id === id) ?? ROUTES[0]

  return (
    <div className="flex w-full max-w-sm flex-col items-center">
      <RouteSwitcher value={route.id} onChange={setId} />
      <RouteSheet route={route} />
    </div>
  )
}

/** Gallery card: the sheet at desktop ratio, centered on the canvas. */
export function RouteSheetDemo() {
  return (
    <div className="h-full">
      <ScaledDesignCanvas>
        <div className="flex h-full items-center justify-center bg-background">
          <RouteSheetStage />
        </div>
      </ScaledDesignCanvas>
    </div>
  )
}

/** Detail and homepage: natural size, centered in the frame. */
export function RouteSheetDetail() {
  return (
    <div className="flex h-full items-center justify-center bg-background p-4">
      <RouteSheetStage />
    </div>
  )
}