Category barFree
One bar split into named ranges with an optional marker, read as one sentence. Plain HTML: no chart engine.
npx shadcn@latest add https://beautifulcharts.dev/r/category-bar.jsonUsage
A complete client module. Install the item first, then paste it into your app.
"use client";
import { CategoryBar } from "@/components/beautiful-charts/themes/instrument/category-bar";
const bands = [
{ band: "At risk", size: 50, color: "var(--bc-bad, #c0341d)" },
{ band: "Watch", size: 20 },
{ band: "Healthy", size: 30, color: "var(--bc-good, #0f7c6e)" },
];
// The marker names the band it falls in; a value beyond the scale is said to be beyond, never inside.
export function AccountHealth() {
return <CategoryBar data={bands} nameKey="band" valueKey="size" colorKey="color" marker={72} markerLabel="This account" label="Health score" />;
}
Props
data*
TDatum[]
One row per range, in order.
nameKey*
keyof TDatum & string
The range's name.
valueKey*
keyof TDatum & string
The range's size (its width on the scale).
colorKey
keyof TDatum & string
Optional CSS colour for each range; otherwise the theme's palette, in order.
start
number
Where the scale starts (default 0).
marker
number | null
A value to mark on the scale.
| Prop | Type | Description |
|---|---|---|
| data* | TDatum[] | One row per range, in order. |
| nameKey* | keyof TDatum & string | The range's name. |
| valueKey* | keyof TDatum & string | The range's size (its width on the scale). |
| colorKey | keyof TDatum & string | Optional CSS colour for each range; otherwise the theme's palette, in order. |
| start | number | Where the scale starts (default 0). |
| marker | number | null | A value to mark on the scale. |
Sourcecomponents/beautiful-charts/themes/instrument/category-bar.tsx
Show the source (52 lines)
"use client";
import { useEffect, useState, type CSSProperties } from "react";
import { useCategoryBarModel, type CategoryBarProps } from "../../core/category-bar";
import { InstrumentStyles } from "./styles";
export type CategoryBarChartProps<TDatum extends Record<string, unknown>> = CategoryBarProps<TDatum> & {
/** Show each range's bounds in the legend (default true). Bounds sit in the legend, where long or narrow ranges can't collide. */
showLabels?: boolean | undefined;
/** Classes on the bar. */
className?: string | undefined;
};
/** Sample data used when you install the category bar; replace it with your own rows. */
export const categoryBarSample = [
{ band: "At risk", size: 50, color: "var(--i-bad)" }, { band: "Watch", size: 25, color: "var(--i-warn)" }, { band: "Healthy", size: 25, color: "var(--i-good)" },
];
/**
* Category bar, Instrument theme. Plain HTML: no chart engine.
* One bar split into named ranges laid end to end, with an optional marker for where a value falls. It isn't
* interactive; screen readers get one sentence naming every range and where the marker is.
*/
export function CategoryBar<TDatum extends Record<string, unknown>>(props: CategoryBarChartProps<TDatum>) {
const m = useCategoryBarModel(props);
const [entered, setEntered] = useState(false);
// Entered on the next frame (not synchronously in the effect), so the entry animation has a first frame to start from.
useEffect(() => { const frame = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(frame); }, []);
const showLabels = props.showLabels ?? true;
// One coordinate system: each range sits at its own place on the scale, and the marker on the same scale.
const pct = (v: number) => (m.span > 0 ? ((v - m.start) / m.span) * 100 : 0);
return (
<div data-slot="category-bar" data-bc-theme={"Instrument".toLowerCase()} data-entered={entered && m.k > 0 ? "" : undefined} className={props.className}
role="img" aria-label={m.summary} style={{ ["--bc-k" as string]: m.k } as CSSProperties}>
<InstrumentStyles />
<div data-slot="category-bar-track" aria-hidden="true">
{m.segments.map((s, i) => (
<span key={s.key} data-slot="category-bar-segment" data-first={i === 0 ? "" : undefined}
style={{ left: `${pct(s.from)}%`, width: `${s.share * 100}%`, ["--c" as string]: i, ["--seg" as string]: s.color ?? `var(--bc-palette-${(s.index % 8) + 1})` } as CSSProperties} />
))}
{m.marker ? <span data-slot="category-bar-marker" data-beyond={m.marker.beyond ?? undefined} style={{ left: `${m.marker.position * 100}%` }} /> : null}
</div>
{/* The names, bounds and marker in words, visible and wrapping: nothing depends on hovering. */}
<p data-slot="category-bar-legend" aria-hidden="true">
{m.segments.map((s) => <span key={s.key} style={{ ["--seg" as string]: s.color ?? `var(--bc-palette-${(s.index % 8) + 1})` } as CSSProperties}>{s.name}{showLabels ? ` ${m.format(s.from)}–${m.format(s.to)}` : ""}</span>)}
{m.markerText ? <span data-slot="category-bar-marker-text">{m.markerText}</span> : null}
{!m.segments.length ? <span>No ranges to show.</span> : null}
</p>
</div>
);
}
Details
The other 6 category bar props
markerLabel
string
What the marker is, e.g. "This month".
label
string
What the bar measures, e.g. "Credit score". Names it for screen readers.
valueFormatter
(value: number) => string
Formats the boundaries and the marker.
motion
MotionPreference
"full" (default), "subtle" or "off". Reduced-motion settings always win.
showLabels
boolean
Show each range's bounds in the legend (default true). Bounds sit in the legend, where long or narrow ranges can't collide.
className
string
Classes on the bar.
| Prop | Type | Description |
|---|---|---|
| markerLabel | string | What the marker is, e.g. "This month". |
| label | string | What the bar measures, e.g. "Credit score". Names it for screen readers. |
| valueFormatter | (value: number) => string | Formats the boundaries and the marker. |
| motion | MotionPreference | "full" (default), "subtle" or "off". Reduced-motion settings always win. |
| showLabels | boolean | Show each range's bounds in the legend (default true). Bounds sit in the legend, where long or narrow ranges can't collide. |
| className | string | Classes on the bar. |
Props every chart shares (2): locale, timeZone
locale
string
BCP 47 locale. Defaults to "en-US" so server and client render the same text.
timeZone
string
IANA time zone for dates. Defaults to "UTC" so server and client agree.
| Prop | Type | Description |
|---|---|---|
| locale | string | BCP 47 locale. Defaults to "en-US" so server and client render the same text. |
| timeZone | string | IANA time zone for dates. Defaults to "UTC" so server and client agree. |
- Pointer
- Not interactive: the range names and the marker are written out under the bar.
- Keyboard
- Not focusable: there's nothing to move between.
- Screen readers
- An image (role="img") named by one sentence: every range with its bounds, where the marker is, and any range left out.
- Motion
- The ranges grow from the left in order. With reduced motion it appears settled at once.