v1.0

Progress

Preview

Basic

Loading…

Preview

Clamped

Loading…

Preview

Code

ts
import Progress from '@/components/data/Progress';

src/components/data/Progress.tsx

AI prompt

text
Build a determinate progress bar component in React + TypeScript + Tailwind CSS.

## Look
- Optional header row above the bar (`mb-1 flex items-center justify-between text-[11px]`, slate-600 / dark slate-300): the label on the left, and when `showValue` is set the percentage on the right in semibold tabular nums.
- Track: `h-1.5 w-full overflow-hidden rounded-full`, slate-200 / dark slate-700.
- Fill: full-height, rounded-full, width animated with a 300ms transition. Tones: indigo `bg-indigo-600`, emerald `bg-emerald-500`, amber `bg-amber-500`, rose `bg-rose-500`.

## Behaviour
- Percentage = `value / max * 100`, clamped to 0–100. Values computed from live counts often go over 100, and an unclamped fill paints outside its track. The shown value is `Math.round(pct)%`.

## Accessibility
The track is `role="progressbar"` with `aria-valuenow` set to the rounded percentage, `aria-valuemin={0}` and `aria-valuemax={100}`.

## API
`value: number`, `max?: number` (100), `tone?: 'indigo' | 'emerald' | 'amber' | 'rose'` (indigo), `label?: ReactNode`, `showValue?: boolean` (false), `className?`.

## Demo
Stacked at max-w-md: Seats used 72 (indigo), Storage 38 (emerald), API quota 91 (amber), and "Over budget" at 140 (rose), which clamps to 100%.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { cn } from '@/lib/cn';

const TONE = {
  indigo: 'bg-indigo-600',
  emerald: 'bg-emerald-500',
  amber: 'bg-amber-500',
  rose: 'bg-rose-500',
} as const;

/**
 * A determinate bar. `value` is clamped, because a percentage computed from live
 * counts goes over 100 more often than anyone expects and an overflowing bar
 * paints outside its track.
 */
export default function Progress({
  value,
  max = 100,
  tone = 'indigo',
  label,
  showValue = false,
  className,
}: {
  value: number;
  max?: number;
  tone?: keyof typeof TONE;
  label?: React.ReactNode;
  showValue?: boolean;
  className?: string;
}) {
  const pct = Math.min(100, Math.max(0, (value / max) * 100));
  return (
    <div className={cn('w-full', className)}>
      {(label || showValue) && (
        <div className="mb-1 flex items-center justify-between text-[11px] text-slate-600 dark:text-slate-300">
          {label && <span>{label}</span>}
          {showValue && <span className="font-semibold tabular-nums">{Math.round(pct)}%</span>}
        </div>
      )}
      <div
        role="progressbar"
        aria-valuenow={Math.round(pct)}
        aria-valuemin={0}
        aria-valuemax={100}
        className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"
      >
        <div className={cn('h-full rounded-full transition-[width] duration-300', TONE[tone])} style={{ width: `${pct}%` }} />
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
value*number—
maxnumber100
tonekeyof typeof TONE'indigo'
labelReact.ReactNode—
showValuebooleanfalse
classNamestring—