Bevel
Browse the docs

Time card

A punch clock's time card: each session is a row with its date, time in, time out and how long it ran, and the key stamps the time now onto the next line. For timesheets, focus sessions and on-call shifts.

A contractor's timesheet

Three sessions on the card already. Punch in, then out, and watch the clock stamp each time.

Acme redesign · Sam Okafor · week 43
DateInOutTotal
Total

Clocked out. on this card.

Installation

Add with the CLI

  1. Once per project, add the Bevel registry to components.json.

    components.json
    {
      "registries": {
        "@bevel": "https://bevel-ui.vercel.app/r/{name}.json"
      }
    }
  2. Add Time card. This brings in the Bevel foundation, installs @base-ui/react and cn, and merges the styles into your global CSS.

    Terminal
    npx shadcn@latest add @bevel/time-card
Install manually
  1. Install the dependencies.

    Terminal
    pnpm add @base-ui/react cn
  2. Add the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.

    styles/bevel.css
    /*
     * Bevel foundation. Every component builds on these tokens and utilities;
     * component stylesheets only add their own palettes and shapes.
     *
     * Layers:
     *   1. Light     one source, above and slightly in front of the screen.
     *   2. Depth     named elevations built from the light: recessed, flush,
     *                raised, pressed, object.
     *   3. Material  a surface recipe (leather, plastic, metal) that reads
     *                --bevel-material and --bevel-material-edge.
     *   4. Motion    two curves, a spring, and durations named by purpose.
     *   5. Shape     radii, stitching, and the type scale for amounts.
     */
    
    :root {
      /* 1. Light */
      --bevel-highlight: oklch(1 0 0 / 0.22);
      --bevel-highlight-strong: oklch(1 0 0 / 0.35);
      --bevel-shade: oklch(0 0 0 / 0.32);
      --bevel-shade-strong: oklch(0 0 0 / 0.5);
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
      /* Stamped into a surface: shaded on the upper inner edge, lit below. */
      --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12);
    
      /* 3. Material defaults; components and variants override these. */
      --bevel-material: oklch(0.47 0.085 52);
      --bevel-material-edge: oklch(0.3 0.06 44);
      --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */
      --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
      --bevel-grain-size: 180px;
    
      /* 2. Depth */
      --bevel-depth-recessed:
        inset 0 1px 2px var(--bevel-shade-strong),
        0 1px 0 var(--bevel-highlight);
      --bevel-depth-flush:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade);
      --bevel-depth-raised:
        inset 0 1px 0 var(--bevel-highlight),
        0 1px 0 var(--bevel-material-edge),
        0 2px 0 var(--bevel-material-edge),
        0 3px 5px var(--bevel-shade);
      --bevel-depth-pressed:
        inset 0 1px 2px var(--bevel-shade),
        0 1px 0 var(--bevel-highlight);
      /* A whole object resting on the page. */
      --bevel-depth-object:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade),
        0 0 0 1px var(--bevel-material-edge),
        0 1px 1px var(--bevel-cast),
        0 6px 12px -2px var(--bevel-cast),
        0 18px 32px -8px var(--bevel-cast);
      /* The shadow under a loose object, for filter: drop-shadow(). */
      --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast);
      /* A thin object (a card) lying on another surface. */
      --bevel-depth-sheet:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px oklch(0 0 0 / 0.12),
        0 -1px 2px oklch(0 0 0 / 0.18);
    
      /* 4. Motion */
      --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */
      --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1);
      --bevel-duration-press: 100ms; /* a control giving under a finger */
      --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */
      --bevel-duration-move: 300ms; /* objects moving: cards, drawers */
      --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */
      --bevel-stagger: 30ms;
      --bevel-lift: 3px; /* how far an object rises on hover */
      --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */
      /* Unchosen items in a group sit back in shadow. */
      --bevel-dim: 0.82;
    
      /* 5. Shape */
      --bevel-radius-xs: 0.25rem;
      --bevel-radius-sm: 0.5rem;
      --bevel-radius-md: 0.625rem;
      --bevel-radius-lg: 1rem;
      --bevel-radius-xl: 1.5rem;
      --bevel-stitch-width: 1.5px;
      --bevel-stitch-inset: 0.375rem;
      /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */
      --bevel-amount: 2.25rem;
      --bevel-amount-minor: 1.25rem;
      --bevel-label: 0.75rem;
      --bevel-fine: 0.6875rem;
      --bevel-tracking-label: 0.08em; /* stamped labels */
      --bevel-tracking-print: 0.06em; /* printed marks on cards */
    
      /* Paper and ink */
      --bevel-paper: oklch(0.975 0.008 85);
      --bevel-paper-foreground: oklch(0.28 0.02 260);
      --bevel-paper-muted: oklch(0.48 0.015 260);
      --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45);
      --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45);
      --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
      /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */
      --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E");
      --bevel-ink-red: oklch(0.52 0.19 27);
      --bevel-ink-blue: oklch(0.44 0.14 262);
      --bevel-ink-green: oklch(0.5 0.12 155);
      --bevel-ink-black: oklch(0.3 0.01 260);
      --bevel-ink-amber: oklch(0.55 0.13 65);
    
      /* Brass and ivory: instrument hardware and dial faces */
      --bevel-brass: oklch(0.76 0.11 82);
      --bevel-brass-edge: oklch(0.52 0.08 70);
      --bevel-ivory: oklch(0.95 0.025 90);
      --bevel-ivory-foreground: oklch(0.25 0.015 60);
      --bevel-needle: oklch(0.55 0.2 27);
    
      /* Desk stationery: legal pads, pens, pencils, exhibit stickers */
      --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */
      --bevel-pad-foreground: oklch(0.27 0.03 255);
      --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45);
      --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55);
      --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */
      --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */
      --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */
      --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */
      --bevel-sticker-foreground: oklch(0.26 0.03 70);
      /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */
      --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
    }
    
    .dark {
      --bevel-highlight: oklch(1 0 0 / 0.14);
      /* Paper under dim light, not inverted: it's still paper. */
      --bevel-paper: oklch(0.88 0.008 85);
      --bevel-ivory: oklch(0.86 0.022 90);
      --bevel-pad: oklch(0.86 0.07 98);
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /* Depth */
    @utility bevel-recessed {
      background-color: var(--bevel-material-recess);
      box-shadow: var(--bevel-depth-recessed);
    }
    @utility bevel-flush {
      box-shadow: var(--bevel-depth-flush);
    }
    @utility bevel-raised {
      background-color: color-mix(in oklch, var(--bevel-material), white 7%);
      background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%);
      box-shadow: var(--bevel-depth-raised);
    }
    @utility bevel-pressed {
      background-color: color-mix(in oklch, var(--bevel-material), black 4%);
      background-image: none;
      box-shadow: var(--bevel-depth-pressed);
    }
    @utility bevel-object {
      box-shadow: var(--bevel-depth-object);
    }
    
    /* Materials */
    @utility bevel-leather {
      background-color: var(--bevel-material);
      background-image:
        var(--bevel-grain),
        linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade));
      background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%;
      background-blend-mode: soft-light, normal;
    }
    @utility bevel-plastic {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%),
        linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45));
      box-shadow: var(--bevel-depth-sheet);
    }
    @utility bevel-metal {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px);
      box-shadow:
        inset 0 0 0 1px oklch(0 0 0 / 0.25),
        inset 0 1px 0 var(--bevel-highlight-strong);
    }
    
    /* Details */
    @utility bevel-deboss {
      text-shadow: var(--bevel-deboss);
    }
    @utility bevel-stitch {
      border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8));
      border-style: dashed;
      border-width: var(--bevel-stitch-width);
      filter: drop-shadow(0 1px 0 var(--bevel-shade));
    }
    /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */
    @utility bevel-fill {
      background-color: var(--bevel-fill);
      background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18));
      box-shadow: 0 0 6px -1px var(--bevel-fill);
    }
    /* A small indicator light. Colour from --bevel-led. */
    @utility bevel-led {
      background-color: var(--bevel-led);
      background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%);
      box-shadow:
        0 0 6px var(--bevel-led),
        inset 0 -1px 1px oklch(0 0 0 / 0.35),
        0 1px 0 var(--bevel-highlight);
    }
    
    /* Paper: a sheet resting on the surface, with fibre in it. */
    @utility bevel-paper {
      background-color: var(--bevel-paper);
      background-image: var(--bevel-fibre);
      background-size: 240px 240px;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
    /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */
    @utility bevel-ink {
      color: var(--bevel-ink, var(--bevel-ink-red));
      border-color: currentColor;
      mix-blend-mode: multiply;
      mask-image: var(--bevel-ink-texture);
      mask-size: 160px 160px;
    }
    /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */
    @utility bevel-glass {
      pointer-events: none;
      background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%);
    }
    
    /* Material families: one per screen. Set on the screen's container. */
    [data-bevel-family="leather"] {
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
    }
    [data-bevel-family="paper"] {
      --bevel-cast: oklch(0.3 0.01 250 / 0.16);
    }
    .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) {
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /*
     * Ruled paper: rules every --bevel-rule-height and a margin line at
     * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling
     * scrolls with the content.
     */
    @utility bevel-ruled {
      background-color: var(--bevel-sheet, var(--bevel-paper));
      background-image:
        linear-gradient(
          to right,
          transparent calc(var(--bevel-rule-margin) - 1px),
          var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px),
          transparent calc(var(--bevel-rule-margin) + 1px)
        ),
        repeating-linear-gradient(
          to bottom,
          transparent 0 calc(var(--bevel-rule-height) - 1px),
          var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height)
        ),
        var(--bevel-fibre);
      background-size: 100% 100%, 100% 100%, 240px 240px;
      background-attachment: local;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
  3. Copy time-card.tsx into components/ui.

    components/time-card.tsx
    "use client"
    
    import * as React from "react"
    import { Button as ButtonPrimitive } from "@base-ui/react/button"
    import { cn } from "cn"
    import { useBevelFeedback } from "@/lib/feedback"
    
    const DAY = 86_400_000
    
    /** One session on the card. No `out` yet means the clock is still running. */
    type TimeCardEntry = { in: Date | string; out?: Date | string | null }
    type TimeCardPunchEvent = { kind: "in" | "out"; at: Date }
    
    type Formatters = {
      date: (d: Date) => string
      time: (d: Date) => string
      day: (d: Date) => string
      duration: (ms: number) => string
    }
    
    type TimeCardContextValue = {
      open: boolean
      last: { in: Date; out: Date | null } | undefined
      total: number
      format: Formatters | undefined
      punch: () => void
      statusId: string
    }
    
    const TimeCardContext = React.createContext<TimeCardContextValue | null>(null)
    
    function useTimeCard() {
      const context = React.useContext(TimeCardContext)
      if (!context) throw new Error("TimeCard parts must be inside <TimeCard>")
      return context
    }
    
    const toDate = (d: Date | string) => (typeof d === "string" ? new Date(d) : d)
    
    function formatters(locale: string | undefined, timeZone: string): Formatters {
      const date = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", timeZone })
      const time = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone })
      const day = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone })
      const hours = new Intl.NumberFormat(locale, { style: "unit", unit: "hour", unitDisplay: "narrow" })
      const minutes = new Intl.NumberFormat(locale, { style: "unit", unit: "minute", unitDisplay: "narrow" })
      return {
        date: (d) => date.format(d),
        time: (d) => time.format(d),
        day: (d) => day.format(d),
        duration: (ms) => {
          const total = Math.max(0, Math.round(ms / 60_000))
          const h = Math.floor(total / 60)
          const m = total % 60
          return h ? (m ? `${hours.format(h)} ${minutes.format(m)}` : hours.format(h)) : minutes.format(m)
        },
      }
    }
    
    type TimeCardProps = Omit<React.ComponentProps<"div">, "title"> & {
      /** Whose card, and for when: printed at the top and used as the table's caption. */
      title: string
      /** Sessions, oldest first. */
      entries: TimeCardEntry[]
      /** The clock was punched: add a session (in) or close the open one (out). */
      onPunch?: (event: TimeCardPunchEvent) => void
      /** Ruled lines on the card; it grows past this. */
      rows?: number
      locale?: string
      /** Pass one to print times on the server too; otherwise they print once the browser's zone is known. */
      timeZone?: string
    }
    
    /**
     * A punched time card: each session is a row with its date, the time in,
     * the time out and how long it lasted. TimeCardPunch stamps the current
     * time on the next line.
     */
    function TimeCard({
      className,
      title,
      entries,
      onPunch,
      rows = 6,
      locale,
      timeZone: timeZoneProp,
      children,
      ...props
    }: TimeCardProps) {
      const statusId = React.useId()
      const feedback = useBevelFeedback()
      // The browser's zone is only known after mount; printing before then would
      // disagree with the server's render.
      const [clientZone, setClientZone] = React.useState<string>()
      React.useEffect(() => {
        if (!timeZoneProp) setClientZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
      }, [timeZoneProp])
      const timeZone = timeZoneProp ?? clientZone
      const format = React.useMemo(() => (timeZone ? formatters(locale, timeZone) : undefined), [locale, timeZone])
    
      const sessions = entries.map((e) => ({ in: toDate(e.in), out: e.out ? toDate(e.out) : null }))
      const last = sessions.at(-1)
      const open = !!last && !last.out
      const total = sessions.reduce((sum, s) => (s.out ? sum + (s.out.getTime() - s.in.getTime()) : sum), 0)
    
      // Stamps already on the card when it appeared don't land again.
      const [initial] = React.useState(() => new Set(sessions.flatMap((s, i) => (s.out ? [`${i}in`, `${i}out`] : [`${i}in`]))))
    
      const punch = React.useCallback(() => {
        feedback.thunk()
        onPunch?.({ kind: open ? "out" : "in", at: new Date() })
      }, [feedback, onPunch, open])
    
      const stamp = (d: Date | null, key: string) =>
        d ? (
          <time
            dateTime={d.toISOString()}
            data-slot="time-card-stamp"
            data-fresh={initial.has(key) ? undefined : ""}
            className={cn(
              "bevel-ink inline-block font-mono tabular-nums [--bevel-ink:var(--time-card-ink)]",
              "data-fresh:transition-[scale,opacity] data-fresh:duration-(--bevel-duration-move) data-fresh:ease-(--bevel-ease-spring)",
              "data-fresh:starting:scale-(--time-card-land-scale) data-fresh:starting:opacity-0 motion-reduce:transition-none"
            )}
          >
            {format ? format.time(d) : "··:··"}
          </time>
        ) : null
    
      return (
        <TimeCardContext value={{ open, last, total, format, punch, statusId }}>
          <div
            data-slot="time-card"
            data-open={open ? "" : undefined}
            className={cn("flex w-full max-w-sm flex-col items-stretch gap-3", className)}
            {...props}
          >
            <div data-slot="time-card-card" className="bevel-paper rounded-(--bevel-radius-xs) px-3 pt-3 pb-4">
              <p
                aria-hidden
                className="border-b-2 border-(--time-card-header) pb-1 text-center text-sm font-black tracking-(--bevel-tracking-label) text-(--time-card-header)"
              >
                TIME CARD
              </p>
              <table className="w-full border-collapse text-sm">
                <caption className="py-1.5 text-left text-xs font-semibold">{title}</caption>
                <thead className="text-(length:--bevel-fine) tracking-(--bevel-tracking-print) text-(--bevel-paper-muted) uppercase">
                  <tr className="border-y border-(--time-card-rule)">
                    <th scope="col" className="py-1 pr-2 text-left font-semibold">Date</th>
                    <th scope="col" className="border-l border-(--time-card-rule) px-2 py-1 text-left font-semibold">In</th>
                    <th scope="col" className="border-l border-(--time-card-rule) px-2 py-1 text-left font-semibold">Out</th>
                    <th scope="col" className="border-l border-(--time-card-rule) py-1 pl-2 text-right font-semibold">Total</th>
                  </tr>
                </thead>
                <tbody>
                  {sessions.map((s, i) => {
                    const overnight = format && s.out ? Math.round((Date.parse(format.day(s.out)) - Date.parse(format.day(s.in))) / DAY) : 0
                    return (
                      <tr key={`${s.in.getTime()}-${i}`} className="h-(--time-card-line) border-b border-(--time-card-rule)">
                        <th scope="row" className="pr-2 text-left text-xs font-medium whitespace-nowrap">
                          {format ? format.date(s.in) : ""}
                        </th>
                        <td className="border-l border-(--time-card-rule) px-2">{stamp(s.in, `${i}in`)}</td>
                        <td className="border-l border-(--time-card-rule) px-2 whitespace-nowrap">
                          {stamp(s.out, `${i}out`)}
                          {overnight > 0 && (
                            <sup className="ml-0.5 text-(length:--bevel-fine) text-(--bevel-paper-muted)">
                              +{overnight}
                              <span className="sr-only"> day{overnight === 1 ? "" : "s"}</span>
                            </sup>
                          )}
                          {!s.out && <span className="sr-only">Still clocked in</span>}
                        </td>
                        <td className="border-l border-(--time-card-rule) pl-2 text-right font-mono text-xs whitespace-nowrap tabular-nums">
                          {s.out && format ? format.duration(s.out.getTime() - s.in.getTime()) : ""}
                        </td>
                      </tr>
                    )
                  })}
                  {Array.from({ length: Math.max(0, rows - sessions.length) }, (_, i) => (
                    <tr key={`empty-${i}`} aria-hidden className="h-(--time-card-line) border-b border-(--time-card-rule)">
                      <td />
                      <td className="border-l border-(--time-card-rule)" />
                      <td className="border-l border-(--time-card-rule)" />
                      <td className="border-l border-(--time-card-rule)" />
                    </tr>
                  ))}
                </tbody>
                <tfoot>
                  <tr>
                    <th scope="row" colSpan={3} className="pt-2 text-right text-(length:--bevel-fine) font-semibold tracking-(--bevel-tracking-print) uppercase">
                      Total
                    </th>
                    <td className="pt-2 pl-2 text-right font-mono text-sm font-bold whitespace-nowrap tabular-nums">
                      {format ? format.duration(total) : ""}
                    </td>
                  </tr>
                </tfoot>
              </table>
            </div>
            {children}
          </div>
        </TimeCardContext>
      )
    }
    
    /** Clocked in or out, in words: “Clocked in at 09:02.” Describes the punch button. */
    function TimeCardStatus({ className, ...props }: React.ComponentProps<"p">) {
      const { open, last, total, format, statusId } = useTimeCard()
      const text = !last
        ? "Not clocked in yet."
        : open
          ? `Clocked in${format ? ` at ${format.time(last.in)}` : ""}.`
          : `Clocked out${format && last.out ? ` at ${format.time(last.out)}` : ""}. ${format ? format.duration(total) : ""} on this card.`
      return (
        <p
          id={statusId}
          role="status"
          data-slot="time-card-status"
          className={cn("flex items-center gap-2 text-sm", className)}
          {...props}
        >
          <span aria-hidden className="bevel-led size-2 shrink-0 rounded-full" />
          {text}
        </p>
      )
    }
    
    /** The punch clock's key. Stamps the time now: in if you're out, out if you're in. */
    function TimeCardPunch({ className, children, onClick, ...props }: ButtonPrimitive.Props) {
      const { open, punch, statusId } = useTimeCard()
      return (
        <ButtonPrimitive
          data-slot="time-card-punch"
          data-kind={open ? "out" : "in"}
          aria-describedby={statusId}
          onClick={(event) => {
            onClick?.(event)
            punch()
          }}
          className={cn(
            "bevel-raised flex h-12 items-center justify-center gap-2 rounded-(--bevel-radius-md) px-5 text-sm font-bold tracking-(--bevel-tracking-label) text-(--time-card-key-foreground) uppercase outline-none",
            "transition-[translate,box-shadow] duration-(--bevel-duration-press) ease-(--bevel-ease-out) active:translate-y-1 active:bevel-pressed motion-reduce:transition-none",
            "focus-visible:ring-3 focus-visible:ring-ring/60 data-disabled:cursor-not-allowed data-disabled:opacity-50",
            className
          )}
          {...props}
        >
          {children ?? (open ? "Punch out" : "Punch in")}
        </ButtonPrimitive>
      )
    }
    
    export { TimeCard, TimeCardStatus, TimeCardPunch }
    export type { TimeCardProps, TimeCardEntry, TimeCardPunchEvent }
  4. Copy time-card.css into your styles.

    styles/time-card.css
    /*
     * Time card: a manila card from a punch clock. Each session is a ruled
     * row, and the clock stamps the time in purple ink. Builds on bevel.css
     * (paper, ink, raised, motion).
     */
    
    :root {
      --time-card-paper: oklch(0.91 0.045 85); /* manila */
      --time-card-rule: oklch(0.62 0.06 150 / 0.45); /* green ruling */
      --time-card-header: oklch(0.45 0.09 150);
      --time-card-ink: oklch(0.42 0.14 300); /* clock ribbon purple */
      --time-card-key: oklch(0.3 0.01 260);
      --time-card-key-edge: oklch(0.18 0.01 260);
      --time-card-key-foreground: oklch(0.95 0.01 90);
      --time-card-in: oklch(0.68 0.16 150);
      --time-card-out: oklch(0.45 0.01 260);
      --time-card-line: 2rem;
      --time-card-land-scale: 1.4; /* the stamp starts big and lands */
    }
    
    .dark {
      --time-card-paper: oklch(0.84 0.04 85);
    }
    
    [data-slot="time-card-card"] {
      --bevel-paper: var(--time-card-paper);
    }
    [data-slot="time-card"] {
      --bevel-led: var(--time-card-out);
    }
    [data-slot="time-card"][data-open] {
      --bevel-led: var(--time-card-in);
    }
    [data-slot="time-card-punch"] {
      --bevel-material: var(--time-card-key);
      --bevel-material-edge: var(--time-card-key-edge);
    }
    
    @media (forced-colors: active) {
      [data-slot="time-card-punch"] {
        border: 1px solid ButtonText;
      }
    }
  5. Import the stylesheets after Tailwind in your global CSS, foundation first.

    globals.css
    @import "./bevel.css";
    @import "./time-card.css";

Anatomy

Each part is its own component. Leave out the ones you don't need.

Anatomy
<TimeCard title="Acme redesign · week 43" entries={entries} onPunch={punch}>
  <TimeCardStatus />
  <TimeCardPunch />
</TimeCard>

Parts

PartWhat it does
TimeCarddiv + tabletitle (the table's caption), entries ({ in, out? }, oldest first; no out means still clocked in), onPunch({ kind, at }), rows, locale, timeZone. Durations and times print with Intl; without a timeZone they print once the browser's is known. New stamps land; the history doesn't.
TimeCardStatusp (status)“Clocked in at 09:04.” or “Clocked out at 17:42. 10h 46m on this card.” Describes the punch key.
TimeCardPunchButtonPunch in, or Punch out while a session is open. Calls onPunch with the time now.