Chart
Recharts wrapped in a ChartContainer that maps series to --chart-N colors, with a shared tooltip and legend.
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent,} from "@/components/ui/chart";import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
const data = [ { day: "Sep 1", mag4: 4.0, spyx: 2.0 }, { day: "Sep 7", mag4: 5.6, spyx: 2.6 }, { day: "Sep 13", mag4: 6.4, spyx: 3.4 }, { day: "Sep 19", mag4: 9.8, spyx: 5.1 }, { day: "Sep 21", mag4: 11.0, spyx: 6.4 }, { day: "Sep 25", mag4: 12.3, spyx: 7.2 }, { day: "Oct 1", mag4: 14.1, spyx: 8.0 },];
const config = { mag4: { label: "MAG4", color: "var(--chart-1)" }, spyx: { label: "SPYx", color: "var(--chart-2)" },} satisfies ChartConfig;
export default function Demo() { return ( <Card className="w-full max-w-lg gap-0 py-0"> <CardHeader className="gap-1 px-6 pt-5.5 pb-0"> <CardTitle className="font-semibold text-base tracking-[-0.2px]"> NAV vs benchmark </CardTitle> <CardDescription className="text-[13.5px]"> Last 30 days </CardDescription> </CardHeader> <CardContent className="items-center px-5 pt-2.5 pb-0"> <ChartContainer config={config} className="aspect-auto h-52 w-full"> <AreaChart data={data} margin={{ left: 4, right: 4 }}> <CartesianGrid vertical={false} /> <XAxis dataKey="day" tickLine={false} axisLine={false} tickMargin={8} /> <ChartTooltip content={<ChartTooltipContent indicator="dot" />} /> <ChartLegend content={<ChartLegendContent />} /> <Area dataKey="spyx" type="monotone" stroke="var(--color-spyx)" strokeDasharray="5 4" strokeWidth={2} fill="var(--color-spyx)" fillOpacity={0.12} /> <Area dataKey="mag4" type="monotone" stroke="var(--color-mag4)" strokeWidth={2} fill="var(--color-mag4)" fillOpacity={0.2} /> </AreaChart> </ChartContainer> </CardContent> <CardFooter className="flex-col items-stretch gap-1 px-6 pt-3.5 pb-5.5 text-[13.5px]"> <b className="font-medium">MAG4 outperformed SPYx by 6.1 pts</b> <span className="text-muted-foreground">Sep 1 to Oct 1</span> </CardFooter> </Card> );}<script setup lang="ts">import { VisArea, VisAxis, VisLine, VisXYContainer } from "@unovis/vue";import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { type ChartConfig, ChartContainer, ChartCrosshair, ChartLegendContent, ChartTooltip, ChartTooltipContent, componentToString,} from "@/components/ui/chart";
type Row = { day: string; mag4: number; spyx: number };
const data: Row[] = [ { day: "Sep 1", mag4: 4.0, spyx: 2.0 }, { day: "Sep 7", mag4: 5.6, spyx: 2.6 }, { day: "Sep 13", mag4: 6.4, spyx: 3.4 }, { day: "Sep 19", mag4: 9.8, spyx: 5.1 }, { day: "Sep 21", mag4: 11.0, spyx: 6.4 }, { day: "Sep 25", mag4: 12.3, spyx: 7.2 }, { day: "Oct 1", mag4: 14.1, spyx: 8.0 },];
const config = { mag4: { label: "MAG4", color: "var(--chart-1)" }, spyx: { label: "SPYx", color: "var(--chart-2)" },} satisfies ChartConfig;
const x = (_: Row, i: number) => i;const tickFormat = (i: number) => data[i]?.day ?? "";const template = componentToString(config, ChartTooltipContent, { indicator: "dot", labelFormatter: (i) => tickFormat(Number(i)),});</script>
<template> <Card class="w-full max-w-lg gap-0 py-0"> <CardHeader class="gap-1 px-6 pt-5.5 pb-0"> <CardTitle class="font-semibold text-base tracking-[-0.2px]">NAV vs benchmark</CardTitle> <CardDescription class="text-[13.5px]">Last 30 days</CardDescription> </CardHeader> <CardContent class="items-center px-5 pt-2.5 pb-0"> <ChartContainer :config="config" class="aspect-auto h-52 w-full" cursor> <VisXYContainer :data="data" :margin="{ left: 4, right: 4 }" :y-domain="[0, undefined]"> <VisArea :x="x" :y="(d: Row) => d.spyx" :color="config.spyx.color" :opacity="0.12" /> <VisLine :x="x" :y="(d: Row) => d.spyx" :color="config.spyx.color" :line-dash-array="[5, 4]" /> <VisArea :x="x" :y="(d: Row) => d.mag4" :color="config.mag4.color" :opacity="0.2" /> <VisLine :x="x" :y="(d: Row) => d.mag4" :color="config.mag4.color" /> <VisAxis type="x" :x="x" :tick-format="tickFormat" :num-ticks="4" :tick-line="false" :domain-line="false" :grid-line="false" /> <ChartTooltip /> <ChartCrosshair :x="x" :y="[(d: Row) => d.mag4, (d: Row) => d.spyx]" :template="template" :color="() => config.mag4.color" /> </VisXYContainer> <ChartLegendContent /> </ChartContainer> </CardContent> <CardFooter class="flex-col items-stretch gap-1 px-6 pt-3.5 pb-5.5 text-[13.5px]"> <b class="font-medium">MAG4 outperformed SPYx by 6.1 pts</b> <span class="text-muted-foreground">Sep 1 to Oct 1</span> </CardFooter> </Card></template><script lang="ts"> import { AreaChart } from "layerchart"; import * as Card from "$lib/components/ui/card"; import * as Chart from "$lib/components/ui/chart";
const days = ["Sep 1", "Sep 7", "Sep 13", "Sep 19", "Sep 21", "Sep 25", "Oct 1"]; const data = [ { i: 0, mag4: 4.0, spyx: 2.0 }, { i: 1, mag4: 5.6, spyx: 2.6 }, { i: 2, mag4: 6.4, spyx: 3.4 }, { i: 3, mag4: 9.8, spyx: 5.1 }, { i: 4, mag4: 11.0, spyx: 6.4 }, { i: 5, mag4: 12.3, spyx: 7.2 }, { i: 6, mag4: 14.1, spyx: 8.0 }, ];
const config = { mag4: { label: "MAG4", color: "var(--chart-1)" }, spyx: { label: "SPYx", color: "var(--chart-2)" }, } satisfies Chart.ChartConfig;</script>
<Card.Root class="w-full max-w-lg gap-0 py-0"> <Card.Header class="gap-1 px-6 pt-5.5 pb-0"> <Card.Title class="font-semibold text-base tracking-[-0.2px]">NAV vs benchmark</Card.Title> <Card.Description class="text-[13.5px]">Last 30 days</Card.Description> </Card.Header> <Card.Content class="items-center px-5 pt-2.5 pb-0"> <Chart.Container {config} class="aspect-auto h-52 w-full"> <AreaChart {data} x="i" legend series={[ { key: "spyx", label: config.spyx.label, color: "var(--color-spyx)" }, { key: "mag4", label: config.mag4.label, color: "var(--color-mag4)" }, ]} props={{ area: { curve: undefined, "fill-opacity": 0.2 }, xAxis: { format: (v: number) => days[v] ?? "", ticks: 4 }, yAxis: { format: () => "" }, }} > {#snippet tooltip()} <Chart.Tooltip indicator="dot" labelFormatter={(v: number) => days[v] ?? ""} /> {/snippet} </AreaChart> </Chart.Container> </Card.Content> <Card.Footer class="flex-col items-stretch gap-1 px-6 pt-3.5 pb-5.5 text-[13.5px]"> <b class="font-medium">MAG4 outperformed SPYx by 6.1 pts</b> <span class="text-muted-foreground">Sep 1 to Oct 1</span> </Card.Footer></Card.Root>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/chartpnpm dlx shadcn@latest add @edmi-ui/chartyarn dlx shadcn@latest add @edmi-ui/chartbunx shadcn@latest add @edmi-ui/chartPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install recharts@3.8.0Terminal window pnpm add recharts@3.8.0Terminal window yarn add recharts@3.8.0Terminal window bun add recharts@3.8.0Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/chartpnpm dlx shadcn-vue@latest add @edmi-ui/chartyarn dlx shadcn-vue@latest add @edmi-ui/chartbunx shadcn-vue@latest add @edmi-ui/chartPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install reka-ui @vueuse/core @unovis/ts @unovis/vueTerminal window pnpm add reka-ui @vueuse/core @unovis/ts @unovis/vueTerminal window yarn add reka-ui @vueuse/core @unovis/ts @unovis/vueTerminal window bun add reka-ui @vueuse/core @unovis/ts @unovis/vueCopy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Copy the code from Component source into your project and update the import paths to match your aliases.
import { Card, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";
<Card className="w-full max-w-lg gap-0 py-0"> <CardHeader className="gap-1 px-6 pt-5.5 pb-0"> <CardTitle className="font-semibold text-base tracking-[-0.2px]"> NAV vs benchmark </CardTitle> <CardDescription className="text-[13.5px]"> Last 30 days </CardDescription> </CardHeader> {/* … */}</Card><script setup lang="ts">import { Card, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";</script>
<template> <Card class="w-full max-w-lg gap-0 py-0"> <CardHeader class="gap-1 px-6 pt-5.5 pb-0"> <CardTitle class="font-semibold text-base tracking-[-0.2px]">NAV vs benchmark</CardTitle> <CardDescription class="text-[13.5px]">Last 30 days</CardDescription> </CardHeader> <!-- … --> </Card></template><script lang="ts"> import * as Card from "$lib/components/ui/card"; import * as Chart from "$lib/components/ui/chart";</script>
<Card.Root class="w-full max-w-lg gap-0 py-0"> <Card.Header class="gap-1 px-6 pt-5.5 pb-0"> <Card.Title class="font-semibold text-base tracking-[-0.2px]">NAV vs benchmark</Card.Title> <Card.Description class="text-[13.5px]">Last 30 days</Card.Description> </Card.Header> <Card.Content class="items-center px-5 pt-2.5 pb-0"> <Chart.Container class="aspect-auto h-52 w-full"> <AreaChart x="i" legend > {#snippet tooltip()} <Chart.Tooltip indicator="dot" /> {/snippet} </AreaChart> </Chart.Container> </Card.Content> <Card.Footer class="flex-col items-stretch gap-1 px-6 pt-3.5 pb-5.5 text-[13.5px]"> <b class="font-medium">MAG4 outperformed SPYx by 6.1 pts</b> <span class="text-muted-foreground">Sep 1 to Oct 1</span> </Card.Footer></Card.Root>Component source
Section titled “Component source”import { cn } from "cn";import * as React from "react";import type { TooltipValueType } from "recharts";import * as RechartsPrimitive from "recharts";
// Format: { THEME_NAME: CSS_SELECTOR }const THEMES = { light: "", dark: ".dark" } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;type TooltipNameType = number | string;
export type ChartConfig = Record< string, { label?: React.ReactNode; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> } )>;
type ChartContextProps = { config: ChartConfig;};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() { const context = React.useContext(ChartContext);
if (!context) { throw new Error("useChart must be used within a <ChartContainer />"); }
return context;}
function ChartContainer({ id, className, children, config, initialDimension = INITIAL_DIMENSION, ...props}: React.ComponentProps<"div"> & { config: ChartConfig; children: React.ComponentProps< typeof RechartsPrimitive.ResponsiveContainer >["children"]; initialDimension?: { width: number; height: number; };}) { const uniqueId = React.useId(); const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return ( <ChartContext.Provider value={{ config }}> <div data-slot="chart" data-chart={chartId} className={cn( "flex aspect-video justify-center font-sans text-xs tabular-nums [&_.recharts-cartesian-axis-tick-value]:fill-muted-foreground [&_.recharts-cartesian-axis-tick-value]:text-xs [&_.recharts-cartesian-grid_line]:[stroke-dasharray:none] [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", className, )} {...props} > <ChartStyle id={chartId} config={config} /> <RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension} > {children} </RechartsPrimitive.ResponsiveContainer> </div> </ChartContext.Provider> );}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { // ✦ Series without an explicit color fall back to --chart-1…5 by order. const colorConfig = Object.entries(config).map( ([key, itemConfig], index) => [ key, itemConfig.theme || itemConfig.color ? itemConfig : { ...itemConfig, color: `var(--chart-${(index % 5) + 1})` }, ] as const, );
if (!colorConfig.length) { return null; }
return ( <style // biome-ignore lint/security/noDangerouslySetInnerHtml: CSS built from the chart config only dangerouslySetInnerHTML={{ __html: Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${id}] {${colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color; return color ? ` --color-${key}: ${color};` : null; }) .join("\n")}}`, ) .join("\n"), }} /> );};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({ active, payload, className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, formatter, color, nameKey, labelKey,}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<"div"> & { hideLabel?: boolean; hideIndicator?: boolean; indicator?: "line" | "dot" | "dashed" | "none"; nameKey?: string; labelKey?: string; } & Omit< RechartsPrimitive.DefaultTooltipContentProps< TooltipValueType, TooltipNameType >, "accessibilityLayer" >) { const { config } = useChart();
const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { return null; }
const [item] = payload; const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const value = !labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
if (labelFormatter) { return ( <div className={cn("font-medium text-foreground", labelClassName)}> {labelFormatter(value, payload)} </div> ); }
if (!value) { return null; }
return ( <div className={cn("font-medium text-foreground", labelClassName)}> {value} </div> ); }, [ label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey, ]);
if (!active || !payload?.length) { return null; }
const showIndicator = !hideIndicator && indicator !== "none";
return ( <div data-slot="chart-tooltip" className={cn( "grid min-w-36 gap-1.5 rounded-lg border border-border bg-popover px-3 py-2 text-popover-foreground text-xs shadow-floating", className, )} > {tooltipLabel} <div className="grid gap-1.5"> {payload .filter((item) => item.type !== "none") .map((item, index) => { const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const indicatorColor = color ?? item.payload?.fill ?? item.color;
return ( <div // biome-ignore lint/suspicious/noArrayIndexKey: recharts payload entries have no stable id key={index} className="flex w-full items-center gap-2 [&>svg]:size-3 [&>svg]:text-muted-foreground" > {formatter && item?.value !== undefined && item.name ? ( formatter(item.value, item.name, item, index, item.payload) ) : ( <> {itemConfig?.icon ? ( <itemConfig.icon /> ) : ( showIndicator && ( <div className={cn("shrink-0", { "size-2 rounded-[2px] bg-(--color-bg)": indicator === "dot", "min-h-3.5 w-1 self-stretch rounded-[2px] bg-(--color-bg)": indicator === "line", "min-h-3.5 w-0 self-stretch border-l-2 border-dashed border-(--color-bg)": indicator === "dashed", })} style={ { "--color-bg": indicatorColor, } as React.CSSProperties } /> ) )} <span className="flex-1 text-muted-foreground"> {itemConfig?.label ?? item.name} </span> {item.value != null && ( <span className="ml-3 whitespace-nowrap font-semibold text-foreground tabular-nums"> {typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)} </span> )} </> )} </div> ); })} </div> </div> );}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey, swatch = "square",}: React.ComponentProps<"div"> & { hideIcon?: boolean; nameKey?: string; /** ✦ `line` draws the 14x2 swatch used by line charts. */ swatch?: "square" | "line";} & RechartsPrimitive.DefaultLegendContentProps) { const { config } = useChart();
if (!payload?.length) { return null; }
return ( <div data-slot="chart-legend" className={cn( "flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-[12.5px] text-foreground-2", verticalAlign === "top" ? "pb-2.5" : "pt-2.5", className, )} > {payload .filter((item) => item.type !== "none") .map((item, index) => { const key = `${nameKey ?? item.dataKey ?? "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key);
return ( <div // biome-ignore lint/suspicious/noArrayIndexKey: recharts payload entries have no stable id key={index} className="flex items-center gap-1.5 [&>svg]:size-3 [&>svg]:text-muted-foreground" > {itemConfig?.icon && !hideIcon ? ( <itemConfig.icon /> ) : ( <div className={cn( "shrink-0", swatch === "line" ? "h-0.5 w-3.5 rounded-[1px]" : "size-2 rounded-[2px]", )} style={{ backgroundColor: item.color, }} /> )} {itemConfig?.label} </div> ); })} </div> );}
/** ✦ Stat well: switches the series of an interactive chart. `active` sinks it (sunken, -1). */function ChartStatWell({ className, active = false, ...props}: React.ComponentProps<"button"> & { active?: boolean }) { return ( <button type="button" data-slot="chart-stat-well" data-active={active ? "" : undefined} aria-pressed={active} className={cn( "flex min-w-36 flex-col justify-center gap-0.5 border-border border-l px-6 py-4 text-left text-muted-foreground text-xs outline-none first:border-l-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring [&_b]:font-semibold [&_b]:text-[26px] [&_b]:text-foreground [&_b]:leading-[1.1] [&_b]:tracking-[-0.6px]", active && "bg-sk-bg shadow-sunken", className, )} {...props} /> );}
function getPayloadConfigFromPayload( config: ChartConfig, payload: unknown, key: string,) { if (typeof payload !== "object" || payload === null) { return undefined; }
const payloadPayload = "payload" in payload && typeof payload.payload === "object" && payload.payload !== null ? payload.payload : undefined;
let configLabelKey: string = key;
if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { configLabelKey = payload[key as keyof typeof payload] as string; } else if ( payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === "string" ) { configLabelKey = payloadPayload[ key as keyof typeof payloadPayload ] as string; }
return configLabelKey in config ? config[configLabelKey] : config[key];}
export { ChartContainer, ChartLegend, ChartLegendContent, ChartStatWell, ChartStyle, ChartTooltip, ChartTooltipContent,};<script lang="ts">import type { HTMLAttributes } from "vue"import type { ChartConfig } from "."import { useId } from "reka-ui"import { computed, toRefs } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { provideChartContext } from "."import ChartStyle from "./ChartStyle.vue"</script>
<script setup lang="ts">const props = defineProps<{ id?: HTMLAttributes["id"] class?: HTMLAttributes["class"] config: ChartConfig cursor?: boolean}>()
defineSlots<{ default: { id: string config: ChartConfig }}>()
const { config } = toRefs(props)const uniqueId = useId()const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, "")}`)
provideChartContext({ id: uniqueId, config,})</script>
<template> <div data-slot="chart" :data-chart="chartId" :class="cn( `[&_.tick_text]:!fill-muted-foreground [&_.tick_text]:!font-sans [&_.tick_text]:!text-xs [&_.tick_line]:!stroke-border font-sans tabular-nums [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `, props.class, )" :style="{ '--vis-tooltip-padding': '0px', '--vis-tooltip-background-color': 'transparent', '--vis-tooltip-border-color': 'transparent', '--vis-tooltip-text-color': 'none', '--vis-tooltip-shadow-color': 'none', '--vis-tooltip-backdrop-filter': 'none', '--vis-crosshair-circle-stroke-color': '#0000', '--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px', '--vis-font-family': 'var(--font-sans)', '--vis-axis-grid-color': 'var(--border)', '--vis-axis-tick-label-color': 'var(--muted-foreground)', '--vis-axis-tick-label-font-size': '12px', }" > <slot :id="uniqueId" :config="config" /> <ChartStyle :id="chartId" /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed, onMounted, ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { chartColor, useChart } from "."
const props = withDefaults(defineProps<{ hideIcon?: boolean nameKey?: string verticalAlign?: "bottom" | "top" /** ✦ `line` draws the 14x2 swatch used by line charts. */ swatch?: "square" | "line" // payload?: any[] class?: HTMLAttributes["class"]}>(), { verticalAlign: "bottom", swatch: "square",})
const { id, config } = useChart()
const payload = computed(() => Object.entries(config.value).map(([key, value]) => { return { key: props.nameKey || key, itemConfig: value, color: chartColor(config.value, props.nameKey || key), }}))
const containerSelector = ref("")onMounted(() => { containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`})</script>
<template> <div v-if="containerSelector" data-slot="chart-legend" :class="cn( 'flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 text-[12.5px] text-foreground-2', verticalAlign === 'top' ? 'pb-2.5' : 'pt-2.5', props.class, )" > <div v-for="{ key, itemConfig, color } in payload" :key="key" :class="cn( '[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3', )" > <component :is="itemConfig.icon" v-if="itemConfig.icon" /> <div v-else :class="cn('shrink-0', swatch === 'line' ? 'h-0.5 w-3.5 rounded-[1px]' : 'size-2 rounded-[2px]')" :style="{ backgroundColor: color, }" />
{{ itemConfig.label }} </div> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{ active?: boolean class?: HTMLAttributes["class"]}>(), { active: false,})</script>
<template> <button type="button" data-slot="chart-stat-well" :data-active="active ? '' : undefined" :aria-pressed="active" :class="cn( 'flex min-w-36 flex-col justify-center gap-0.5 border-border border-l px-6 py-4 text-left text-muted-foreground text-xs outline-none first:border-l-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring [&_b]:font-semibold [&_b]:text-[26px] [&_b]:text-foreground [&_b]:leading-[1.1] [&_b]:tracking-[-0.6px]', active && 'bg-sk-bg shadow-sunken', props.class, )" > <slot /> </button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { computed } from "vue"import { chartColor, THEMES, useChart } from "."
defineProps<{ id?: HTMLAttributes["id"]}>()
const { config } = useChart()
const colorConfig = computed(() => { // ✦ Series without a color fall back to --chart-1…5 by order. return Object.entries(config.value).map(([key, item]) => [ key, item.theme || item.color ? item : { ...item, color: chartColor(config.value, key) }, ] as const)})</script>
<template> <Primitive v-if="colorConfig.length" as="style" > {{ Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${id}] {${colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color return color ? ` --color-${key}: ${color};` : null }) .join("\n")}}`, ) .join("\n") }} </Primitive></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { ChartConfig } from "."import { computed } from "vue"import { chartColor } from "."import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<{ hideLabel?: boolean hideIndicator?: boolean indicator?: "line" | "dot" | "dashed" | "none" nameKey?: string labelKey?: string labelFormatter?: (d: number | Date) => string /** Custom row text: receives the series value, key and config. */ formatter?: (value: unknown, key: string, itemConfig: ChartConfig[string] | undefined) => string payload?: Record<string, any> config?: ChartConfig class?: HTMLAttributes["class"] color?: string x?: number | Date}>(), { payload: () => ({}), config: () => ({}), indicator: "dot",})
// TODO: currently we use `createElement` and `render` to render the// const chartContext = useChart(null)
const payload = computed(() => { return Object.entries(props.payload).map(([key, value]) => { // const key = `${props.nameKey || item.name || item.dataKey || "value"}` const itemConfig = props.config[key] const indicatorColor = props.color ?? chartColor(props.config, key) ?? props.payload.fill
return { key, value, itemConfig, indicatorColor } }).filter(i => i.itemConfig)})
const showIndicator = computed(() => !props.hideIndicator && props.indicator !== "none")const tooltipLabel = computed(() => { if (props.hideLabel) return null if (props.labelFormatter && props.x !== undefined) { return props.labelFormatter(props.x) } return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x})</script>
<template> <div data-slot="chart-tooltip" :class="cn( 'grid min-w-36 gap-1.5 rounded-lg border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-floating', props.class, )" > <slot> <div v-if="tooltipLabel" class="font-medium text-foreground"> {{ tooltipLabel }} </div> <div class="grid gap-1.5"> <div v-for="{ value, itemConfig, indicatorColor, key } in payload" :key="key" class="[&>svg]:text-muted-foreground flex w-full items-center gap-2 [&>svg]:size-3" > <component :is="itemConfig.icon" v-if="itemConfig?.icon" /> <template v-else-if="showIndicator"> <div :class="cn('shrink-0', { 'size-2 rounded-[2px] bg-(--color-bg)': indicator === 'dot', 'min-h-3.5 w-1 self-stretch rounded-[2px] bg-(--color-bg)': indicator === 'line', 'min-h-3.5 w-0 self-stretch border-l-2 border-dashed border-(--color-bg)': indicator === 'dashed', })" :style="{ '--color-bg': indicatorColor }" /> </template> <template v-if="formatter"> <span class="flex-1 text-muted-foreground">{{ formatter(value, key, itemConfig) }}</span> </template> <template v-else> <span class="flex-1 text-muted-foreground"> {{ itemConfig?.label || value }} </span> <span v-if="value" class="ml-3 whitespace-nowrap font-semibold text-foreground tabular-nums"> {{ value.toLocaleString() }} </span> </template> </div> </div> </slot> </div></template>import { createContext } from "reka-ui";import type { Component, Ref } from "vue";
export { default as ChartContainer } from "./ChartContainer.vue";export { default as ChartLegendContent } from "./ChartLegendContent.vue";export { default as ChartStatWell } from "./ChartStatWell.vue";export { default as ChartTooltipContent } from "./ChartTooltipContent.vue";export { componentToString } from "./utils";
// Format: { THEME_NAME: CSS_SELECTOR }export const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = { [k in string]: { label?: string | Component; icon?: string | Component; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> } );};
interface ChartContextProps { id: string; config: Ref<ChartConfig>;}
export const [useChart, provideChartContext] = createContext<ChartContextProps>("Chart");
/** ✦ Series without an explicit color fall back to --chart-1…5 by order. */export function chartColor( config: ChartConfig, key: string,): string | undefined { const item = config[key]; if (item?.color) return item.color; if (item?.theme) return `var(--color-${key})`; const index = Object.keys(config).indexOf(key); return index < 0 ? undefined : `var(--chart-${(index % 5) + 1})`;}
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip,} from "@unovis/vue";import { isClient } from "@vueuse/core";import { useId } from "reka-ui";import { h, render } from "vue";import type { ChartConfig } from ".";
// Simple cache using a Map to store serialized object keysconst cache = new Map<string, string>();
// Convert object to a consistent string keyfunction serializeKey(key: Record<string, unknown>): string { return JSON.stringify(key, Object.keys(key).sort());}
interface Constructor<P = unknown> { __isFragment?: never; __isTeleport?: never; __isSuspense?: never; new ( ...args: never[] ): { $props: P; };}
export function componentToString<P>( config: ChartConfig, component: Constructor<P>, props?: P,) { if (!isClient) return;
// This function will be called once during mount lifecycle const id = useId();
// https://unovis.dev/docs/auxiliary/Crosshair#component-props return (_data: Record<string, unknown>, x: number | Date) => { const data = ("data" in _data ? _data.data : _data) as Record< string, unknown >; const serializedKey = `${id}-${serializeKey(data)}`; const cachedContent = cache.get(serializedKey); if (cachedContent) return cachedContent;
const vnode = h<unknown>(component, { ...props, payload: data, config, x }); const div = document.createElement("div"); render(vnode, div); cache.set(serializedKey, div.innerHTML); return div.innerHTML; };}<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import ChartStyle from "./chart-style.svelte"; import { setChartContext, type ChartConfig } from "./chart-utils.js"; import type { HTMLAttributes } from "svelte/elements";
const uid = $props.id();
let { ref = $bindable(null), id = uid, class: className, children, config, ...restProps }: WithElementRef<HTMLAttributes<HTMLElement>> & { config: ChartConfig; } = $props();
const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`);
setChartContext({ get config() { return config; }, });</script>
<div bind:this={ref} data-chart={chartId} data-slot="chart" class={cn( "flex aspect-video justify-center overflow-visible font-sans text-xs tabular-nums", // Overrides // // Stroke around dots/marks when hovering "[&_.lc-highlight-point]:stroke-transparent", // override the default stroke color of lines "[&_.lc-line]:stroke-border [&_.lc-grid-x-rule]:[stroke-dasharray:none] [&_.lc-grid-y-rule]:[stroke-dasharray:none]",
// by default, layerchart shows a line intersecting the point when hovering, this hides that "[&_.lc-highlight-line]:stroke-0",
// by default, when you hover a point on a stacked series chart, it will drop the opacity // of the other series, this overrides that "[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible",
// We don't want the little tick lines between the axis labels and the chart, so we remove // the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every // chart. "[&_.lc-axis-tick]:stroke-0",
// We don't want to display the rule on the x/y axis, as there is already going to be // a grid line there and rule ends up overlapping the marks because it is rendered after // the marks "[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0", "[&_.lc-grid-x-radial-circle]:stroke-border [&_.lc-grid-x-radial-line]:stroke-border", "[&_.lc-grid-y-radial-circle]:stroke-border [&_.lc-grid-y-radial-line]:stroke-border",
// Legend adjustments "[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5", "[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4", "[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
// Labels "[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
// Tick labels on th x/y axes "[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-sans [&_.lc-axis-tick-label]:text-xs [&_.lc-axis-tick-label]:font-normal", "[&_.lc-tooltip-rects-g]:fill-transparent", "[&_.lc-layout-svg-g]:fill-transparent", "[&_.lc-root-container]:w-full", className )} {...restProps}> <ChartStyle id={chartId} {config} /> {@render children?.()}</div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, active = false, children, ...restProps }: WithElementRef<HTMLButtonAttributes> & { active?: boolean } = $props();</script>
<button bind:this={ref} type="button" data-slot="chart-stat-well" data-active={active ? "" : undefined} aria-pressed={active} class={cn( "flex min-w-36 flex-col justify-center gap-0.5 border-border border-l px-6 py-4 text-left text-muted-foreground text-xs outline-none first:border-l-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring [&_b]:font-semibold [&_b]:text-[26px] [&_b]:text-foreground [&_b]:leading-[1.1] [&_b]:tracking-[-0.6px]", active && "bg-sk-bg shadow-sunken", className )} {...restProps}> {@render children?.()}</button><script lang="ts"> import { THEMES, chartColor, type ChartConfig } from "./chart-utils.js";
let { id, config }: { id: string; config: ChartConfig } = $props();
const colorConfig = $derived( config ? Object.entries(config).map( ([key, item]) => [key, item.theme || item.color ? item : { ...item, color: chartColor(config, key) }] as const ) : null );
const themeContents = $derived.by(() => { if (!colorConfig || !colorConfig.length) return;
const themeContents = []; for (const [_theme, prefix] of Object.entries(THEMES)) { let content = `${prefix} [data-chart=${id}] {\n`; const color = colorConfig.map(([key, itemConfig]) => { const theme = _theme as keyof typeof itemConfig.theme; const color = itemConfig.theme?.[theme] || itemConfig.color; return color ? `\t--color-${key}: ${color};` : null; });
content += color.join("\n") +"\n}";
themeContents.push(content); }
return themeContents.join("\n"); });</script>
{#if themeContents} {#key id} <svelte:element this={"style"}> {themeContents} </svelte:element> {/key}{/if}<script lang="ts"> import { getChartContext, Tooltip as TooltipPrimitive } from "layerchart"; import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; import { chartColor, getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
// eslint-disable-next-line @typescript-eslint/no-explicit-any function defaultFormatter(value: any, _payload: TooltipPayload[]) { return `${value}`; }
let { ref = $bindable(null), class: className, hideLabel = false, indicator = "dot", hideIndicator = false, labelKey, label, labelFormatter = defaultFormatter, labelClassName, formatter, nameKey, color, ...restProps }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & { hideLabel?: boolean; label?: string; indicator?: "line" | "dot" | "dashed" | "none"; nameKey?: string; labelKey?: string; hideIndicator?: boolean; labelClassName?: string; labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any ((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null; formatter?: Snippet< [ { value: unknown; name: string; item: TooltipPayload; index: number; payload: TooltipPayload[]; }, ] >; } = $props();
const chart = useChart(); const chartCtx = getChartContext();
// Filter to series with defined values (important for item-based charts like Pie/Arc // where only the hovered item has a value) const visibleSeries = $derived( chartCtx.tooltip.series.filter((s: TooltipPayload) => s.value !== undefined) );
const formattedLabel = $derived.by(() => { if (hideLabel || !visibleSeries?.length) return null;
const [item] = visibleSeries; const tooltipData = chartCtx.tooltip.data;
// Get the x-axis label value from the raw tooltip data (e.g. a Date or month string) const dataLabel = tooltipData != null ? chartCtx.x(tooltipData) : undefined;
const key = labelKey ?? item?.label ?? item?.key ?? "value"; const itemConfig = getPayloadConfigFromPayload( chart.config, item, key, tooltipData as Record<string, unknown> | null );
let value: unknown; if (!labelKey && typeof label === "string") { value = chart.config[label as keyof typeof chart.config]?.label ?? label; } else if (labelKey) { value = itemConfig?.label ?? dataLabel; } else { value = dataLabel; }
if (value === undefined) return null; if (!labelFormatter) return value; return labelFormatter(value, visibleSeries); });
const showIndicator = $derived(!hideIndicator && indicator !== "none");</script>
{#snippet TooltipLabel()} {#if formattedLabel} <div class={cn("font-medium text-foreground", labelClassName)}> {#if typeof formattedLabel === "function"} {@render formattedLabel()} {:else} {formattedLabel} {/if} </div> {/if}{/snippet}
<TooltipPrimitive.Root variant="none"> <div bind:this={ref} class={cn( "grid min-w-36 gap-1.5 rounded-lg border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-floating", className )} {...restProps} > {@render TooltipLabel()} <div class="grid gap-1.5"> {#each visibleSeries as item, i (item.key + i)} {@const key = `${nameKey || item.key || item.label || "value"}`} {@const itemConfig = getPayloadConfigFromPayload( chart.config, item, key, chartCtx.tooltip.data )} {@const indicatorColor = color || item.config?.color || item.color || chartColor(chart.config, item.key)} <div class={cn( "flex w-full items-center gap-2 [&>svg]:size-3 [&>svg]:text-muted-foreground" )} > {#if formatter && item.value !== undefined && item.label} {@render formatter({ value: item.value, name: item.label, item, index: i, payload: visibleSeries, })} {:else} {#if itemConfig?.icon} <itemConfig.icon /> {:else if showIndicator} <div style="--color-bg: {indicatorColor};" class={cn("shrink-0", { "size-2 rounded-[2px] bg-(--color-bg)": indicator === "dot", "min-h-3.5 w-1 self-stretch rounded-[2px] bg-(--color-bg)": indicator === "line", "min-h-3.5 w-0 self-stretch border-l-2 border-dashed border-(--color-bg)": indicator === "dashed", })} ></div> {/if} <span class="flex-1 text-muted-foreground"> {itemConfig?.label || item.label} </span> {#if item.value !== undefined} <span class="ml-3 font-semibold whitespace-nowrap text-foreground tabular-nums"> {item.value.toLocaleString()} </span> {/if} {/if} </div> {/each} </div> </div></TooltipPrimitive.Root>import type { Tooltip } from "layerchart";import { type Component, getContext, type Snippet, setContext } from "svelte";
export const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = { [k in string]: { label?: string; icon?: Component; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> } );};
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
export type TooltipPayload = Tooltip.TooltipSeries;
// Helper to extract item config from a payload.export function getPayloadConfigFromPayload( config: ChartConfig, payload: TooltipPayload, key: string, data?: Record<string, unknown> | null,) { if (typeof payload !== "object" || payload === null) return undefined;
const payloadConfig = "config" in payload && typeof payload.config === "object" && payload.config !== null ? payload.config : undefined;
let configLabelKey: string = key;
if (payload.key === key) { configLabelKey = payload.key; } else if (payload.label === key) { configLabelKey = payload.label; } else if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { configLabelKey = payload[key as keyof typeof payload] as string; } else if ( payloadConfig !== undefined && key in payloadConfig && typeof payloadConfig[key as keyof typeof payloadConfig] === "string" ) { configLabelKey = payloadConfig[key as keyof typeof payloadConfig] as string; } else if (data != null && key in data && typeof data[key] === "string") { configLabelKey = data[key] as string; }
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];}
type ChartContextValue = { config: ChartConfig;};
const chartContextKey = Symbol("chart-context");
export function setChartContext(value: ChartContextValue) { return setContext(chartContextKey, value);}
export function useChart() { return getContext<ChartContextValue>(chartContextKey);}
/** ✦ Series without an explicit color fall back to --chart-1…5 by order. */export function chartColor( config: ChartConfig, key: string,): string | undefined { const item = config[key]; if (item?.color) return item.color; if (item?.theme) return `var(--color-${key})`; const index = Object.keys(config).indexOf(key); return index < 0 ? undefined : `var(--chart-${(index % 5) + 1})`;}import ChartContainer from "./chart-container.svelte";import ChartStatWell from "./chart-stat-well.svelte";import ChartTooltip from "./chart-tooltip.svelte";
export { type ChartConfig, chartColor, getPayloadConfigFromPayload,} from "./chart-utils.js";
export { ChartContainer, ChartContainer as Container, ChartStatWell, ChartStatWell as StatWell, ChartTooltip, ChartTooltip as Tooltip,};Foundations
Section titled “Foundations”Charts live inside a Card, take series colour only from ChartConfig (--color-<key>, defaulting to --chart-1…5 in config order) and never colour text with a series colour.
Palette
Section titled “Palette”| Token | Green · light | Green · dark | Ocean · dark |
|---|---|---|---|
--chart-1 |
#5bb26a |
#3eab5e |
#5a8ff3 |
--chart-2 |
#3b6fd6 |
#5a8ff3 |
#00aaab |
--chart-3 |
#e39a3c |
#c0851f |
#c0851f |
--chart-4 |
#8a7fd6 |
#957ee5 |
#957ee5 |
--chart-5 |
#d9674f |
#e75e6a |
#e75e6a |
Chart colours are ordinary theme tokens, like in shadcn/ui: --chart-1…5 are defined for light (:root) and dark (.dark), and each accent theme carries its own set. The dark sets keep every hue at the same lightness (about L 0.66) so they pass the palette validator (lightness band, chroma floor, colour-blind separation, normal-vision floor, contrast) on both the Stone and Slate dark surfaces. To recolour one series only, use color or theme: { light, dark } in ChartConfig.
In light mode, contrast against the surface is low on green and amber, so every chart also needs a legend, tooltip values and a table view.
- One y-axis, never dual-axis. A sixth series folds into “Other”.
- Bars are at most 24px wide, with a 4px radius on the data end only.
- Touching fills (stack segments, adjacent bars, pie slices) keep a 2px surface-coloured gap; no strokes around marks.
- Legend for two or more series; a pie or donut always has one. Pie and donut hold at most five slices.
- Grid is a solid 1px hairline (
--border), horizontal only for cartesian charts. Axis text is 12pxmuted-foreground; values are tabular numerals. - The tooltip is always floating (+2). Elevation on a chart card is flat, or raised for a featured chart, never floating.
- Offer a table view with the same values as an alternative to the chart.
Chart card
Section titled “Chart card”Compose Card with CardHeader (title, description, optional action: a range Select or ChartStatWell buttons), CardContent (the chart) and CardFooter (trend line, period). Use className="gap-0 py-0" on the card, gap-1 px-6 pt-5.5 pb-0 on the header, items-center px-5 pt-2.5 pb-0 on the content and px-6 pt-3.5 pb-5.5 text-[13.5px] on the footer, as the demos above do.
Chart types
Section titled “Chart types”Every variant from the Edmi Chart Kit, live in React, Vue and Svelte: Area · Bar · Line · Pie & donut · Radar · Radial · Tooltips.
import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent,} from "@/components/ui/chart";import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
const data = [ { day: "M", joins: 62, exits: 37 }, { day: "T", joins: 80, exits: 48 }, { day: "W", joins: 54, exits: 32 }, { day: "T", joins: 96, exits: 58 }, { day: "F", joins: 70, exits: 42 }, { day: "S", joins: 110, exits: 66 }, { day: "S", joins: 88, exits: 53 },];
// No colors given: series fall back to --chart-1, --chart-2 by order (✦).const config = { joins: { label: "Joins" }, exits: { label: "Exits" },} satisfies ChartConfig;
export default function Demo() { return ( <Card className="w-full max-w-md gap-0 py-0"> <CardHeader className="gap-1 px-6 pt-5.5 pb-0"> <CardTitle className="font-semibold text-base tracking-[-0.2px]"> Joins vs exits </CardTitle> <CardDescription className="text-[13.5px]">This week</CardDescription> </CardHeader> <CardContent className="items-center px-5 pt-2.5 pb-0"> <ChartContainer config={config} className="aspect-auto h-48 w-full"> <BarChart data={data}> <CartesianGrid vertical={false} /> <XAxis dataKey="day" tickLine={false} axisLine={false} /> <ChartTooltip content={<ChartTooltipContent indicator="line" />} /> <ChartLegend content={<ChartLegendContent />} /> <Bar dataKey="joins" fill="var(--color-joins)" radius={[4, 4, 0, 0]} maxBarSize={24} /> <Bar dataKey="exits" fill="var(--color-exits)" radius={[4, 4, 0, 0]} maxBarSize={24} /> </BarChart> </ChartContainer> </CardContent> </Card> );}<script setup lang="ts">import { VisAxis, VisGroupedBar, VisXYContainer } from "@unovis/vue";import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";import { type ChartConfig, ChartContainer, ChartCrosshair, ChartLegendContent, ChartTooltip, ChartTooltipContent, componentToString,} from "@/components/ui/chart";
type Row = { day: string; joins: number; exits: number };
const data: Row[] = [ { day: "M", joins: 62, exits: 37 }, { day: "T", joins: 80, exits: 48 }, { day: "W", joins: 54, exits: 32 }, { day: "T", joins: 96, exits: 58 }, { day: "F", joins: 70, exits: 42 }, { day: "S", joins: 110, exits: 66 }, { day: "S", joins: 88, exits: 53 },];
// No colors given: series fall back to --chart-1, --chart-2 by order (✦).const config = { joins: { label: "Joins" }, exits: { label: "Exits" },} satisfies ChartConfig;
const colors = ["var(--chart-1)", "var(--chart-2)"];const x = (_: Row, i: number) => i;const tickFormat = (i: number) => data[i]?.day ?? "";const template = componentToString(config, ChartTooltipContent, { indicator: "line", labelFormatter: (i) => tickFormat(Number(i)),});</script>
<template> <Card class="w-full max-w-md gap-0 py-0"> <CardHeader class="gap-1 px-6 pt-5.5 pb-0"> <CardTitle class="font-semibold text-base tracking-[-0.2px]">Joins vs exits</CardTitle> <CardDescription class="text-[13.5px]">This week</CardDescription> </CardHeader> <CardContent class="items-center px-5 pt-2.5 pb-0"> <ChartContainer :config="config" class="aspect-auto h-48 w-full"> <VisXYContainer :data="data" :y-domain="[0, undefined]"> <VisGroupedBar :x="x" :y="[(d: Row) => d.joins, (d: Row) => d.exits]" :color="(_: Row, i: number) => colors[i]" :rounded-corners="4" /> <VisAxis type="x" :x="x" :tick-format="tickFormat" :num-ticks="7" :tick-line="false" :domain-line="false" :grid-line="false" /> <ChartTooltip /> <ChartCrosshair :x="x" :y="[(d: Row) => d.joins, (d: Row) => d.exits]" :template="template" :color="(_: Row, i: number) => colors[i]" /> </VisXYContainer> <ChartLegendContent /> </ChartContainer> </CardContent> </Card></template><script lang="ts"> import { BarChart } from "layerchart"; import * as Card from "$lib/components/ui/card"; import * as Chart from "$lib/components/ui/chart";
const data = [ { day: "M", joins: 62, exits: 37 }, { day: "T", joins: 80, exits: 48 }, { day: "W", joins: 54, exits: 32 }, { day: "T2", joins: 96, exits: 58 }, { day: "F", joins: 70, exits: 42 }, { day: "S", joins: 110, exits: 66 }, { day: "S2", joins: 88, exits: 53 }, ];
// No colors given: series fall back to --chart-1, --chart-2 by order (✦). const config = { joins: { label: "Joins" }, exits: { label: "Exits" }, } satisfies Chart.ChartConfig;</script>
<Card.Root class="w-full max-w-md gap-0 py-0"> <Card.Header class="gap-1 px-6 pt-5.5 pb-0"> <Card.Title class="font-semibold text-base tracking-[-0.2px]">Joins vs exits</Card.Title> <Card.Description class="text-[13.5px]">This week</Card.Description> </Card.Header> <Card.Content class="items-center px-5 pt-2.5 pb-0"> <Chart.Container {config} class="aspect-auto h-48 w-full"> <BarChart {data} x="day" legend seriesLayout="group" bandPadding={0.25} series={[ { key: "joins", label: "Joins", color: "var(--color-joins)" }, { key: "exits", label: "Exits", color: "var(--color-exits)" }, ]} props={{ bars: { radius: 4, strokeWidth: 0 }, xAxis: { format: (v: string) => v.replace(/2$/, "") }, yAxis: { format: () => "" }, }} > {#snippet tooltip()} <Chart.Tooltip indicator="line" labelFormatter={(v: string) => v.replace(/2$/, "")} /> {/snippet} </BarChart> </Chart.Container> </Card.Content></Card.Root>import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";import { type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent,} from "@/components/ui/chart";import { Cell, Pie, PieChart } from "recharts";
const data = [ { token: "nvdax", weight: 32 }, { token: "msftx", weight: 28 }, { token: "aaplx", weight: 24 }, { token: "anthrp", weight: 16 },];
const config = { weight: { label: "Weight" }, nvdax: { label: "NVDAx", color: "var(--chart-1)" }, msftx: { label: "MSFTx", color: "var(--chart-2)" }, aaplx: { label: "AAPLx", color: "var(--chart-3)" }, anthrp: { label: "ANTHRP-pre", color: "var(--chart-4)" },} satisfies ChartConfig;
export default function Demo() { return ( <Card className="w-full max-w-sm gap-0 py-0"> <CardHeader className="gap-1 px-6 pt-5.5 pb-0"> <CardTitle className="font-semibold text-base tracking-[-0.2px]"> Allocation </CardTitle> <CardDescription className="text-[13.5px]"> MAG4 weights </CardDescription> </CardHeader> <CardContent className="items-center px-5 pt-2.5 pb-0"> <ChartContainer config={config} className="aspect-auto h-60 w-full"> <PieChart> <ChartTooltip content={ <ChartTooltipContent nameKey="token" hideLabel indicator="dashed" /> } /> <Pie data={data} dataKey="weight" nameKey="token" innerRadius={46} stroke="var(--card)" strokeWidth={2} > {data.map((d) => ( <Cell key={d.token} fill={`var(--color-${d.token})`} /> ))} </Pie> <ChartLegend content={<ChartLegendContent nameKey="token" />} /> </PieChart> </ChartContainer> </CardContent> </Card> );}<script setup lang="ts">import { Donut } from "@unovis/ts";import { VisDonut, VisSingleContainer } from "@unovis/vue";import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";import { type ChartConfig, ChartContainer, ChartLegendContent, ChartTooltip, ChartTooltipContent, componentToString,} from "@/components/ui/chart";
type Slice = { token: string; weight: number } & Record<string, string | number>;
// Each slice also carries `[token]: weight` so the tooltip can look up its label.const data: Slice[] = [ { token: "nvdax", weight: 32 }, { token: "msftx", weight: 28 }, { token: "aaplx", weight: 24 }, { token: "anthrp", weight: 16 },].map((d) => ({ ...d, [d.token]: d.weight }));
const config = { nvdax: { label: "NVDAx", color: "var(--chart-1)" }, msftx: { label: "MSFTx", color: "var(--chart-2)" }, aaplx: { label: "AAPLx", color: "var(--chart-3)" }, anthrp: { label: "ANTHRP-pre", color: "var(--chart-4)" },} satisfies ChartConfig;
const value = (d: Slice) => d.weight;const color = (d: Slice) => config[d.token as keyof typeof config].color;const template = componentToString(config, ChartTooltipContent, { hideLabel: true, indicator: "dashed",});const triggers = { [Donut.selectors.segment]: template };</script>
<template> <Card class="w-full max-w-sm gap-0 py-0"> <CardHeader class="gap-1 px-6 pt-5.5 pb-0"> <CardTitle class="font-semibold text-base tracking-[-0.2px]">Allocation</CardTitle> <CardDescription class="text-[13.5px]">MAG4 weights</CardDescription> </CardHeader> <CardContent class="items-center px-5 pt-2.5 pb-0"> <ChartContainer :config="config" class="aspect-auto h-60 w-full"> <VisSingleContainer :data="data" :margin="{ top: 8, bottom: 8 }"> <VisDonut :value="value" :color="color" :arc-width="26" :pad-angle="0.02" /> <ChartTooltip :triggers="triggers" /> </VisSingleContainer> <ChartLegendContent /> </ChartContainer> </CardContent> </Card></template><script lang="ts"> import { PieChart } from "layerchart"; import * as Card from "$lib/components/ui/card"; import * as Chart from "$lib/components/ui/chart";
const config = { nvdax: { label: "NVDAx", color: "var(--chart-1)" }, msftx: { label: "MSFTx", color: "var(--chart-2)" }, aaplx: { label: "AAPLx", color: "var(--chart-3)" }, anthrp: { label: "ANTHRP-pre", color: "var(--chart-4)" }, } satisfies Chart.ChartConfig;
const data = [ { token: "nvdax", weight: 32 }, { token: "msftx", weight: 28 }, { token: "aaplx", weight: 24 }, { token: "anthrp", weight: 16 }, ].map((d) => ({ ...d, name: config[d.token as keyof typeof config].label, color: config[d.token as keyof typeof config].color, }));</script>
<Card.Root class="w-full max-w-sm gap-0 py-0"> <Card.Header class="gap-1 px-6 pt-5.5 pb-0"> <Card.Title class="font-semibold text-base tracking-[-0.2px]">Allocation</Card.Title> <Card.Description class="text-[13.5px]">MAG4 weights</Card.Description> </Card.Header> <Card.Content class="items-center px-5 pt-2.5 pb-0"> <Chart.Container {config} class="aspect-auto h-60 w-full"> <PieChart {data} key="token" label="name" value="weight" c="color" innerRadius={-26} padAngle={0.02} legend props={{ pie: { motion: "tween" } }} > {#snippet tooltip()} <Chart.Tooltip hideLabel nameKey="token" indicator="dashed" /> {/snippet} </PieChart> </Chart.Container> </Card.Content></Card.Root>| Prop | Type | Default |
|---|---|---|
ChartContainer config |
{ [key]: { label?, icon?, color? | theme? } } |
required |
ChartTooltipContent indicator |
dot | line | dashed | none |
dot |
ChartTooltipContent hideLabel / hideIndicator |
boolean |
false |
ChartTooltipContent nameKey / labelKey |
string |
none |
ChartTooltipContent labelFormatter / formatter |
function |
none |
ChartLegendContent hideIcon / nameKey |
boolean / string |
false |
ChartLegendContent swatch ✦ (React, Vue) |
square | line |
square |
ChartStatWell active ✦ |
boolean |
false |
Series colour resolves to var(--color-<key>). The React port needs the recharts package, Vue @unovis/vue, Svelte layerchart. Vue’s ChartTooltipContent takes formatter(value, key, itemConfig); the Svelte Tooltip takes a formatter snippet.
✦ Edmi additions
Section titled “✦ Edmi additions”- Series without
color/themefall back to--chart-1…5by order. - Tooltip is a solid
--popoverpanel, always floating (+2): label, then rows with an indicator, a muted name and a semibold tabular value.indicator="none"hides the marker. - Legend uses an 8px rounded swatch (14x2 with
swatch="line"),foreground-2text, centered. - Grid is solid, axis text is 12px in the UI face.
- Validated dark chart palette as global tokens for the Green and Ocean themes (see Foundations).
ChartStatWell: a button that switches the series of an interactive chart. Put the label in a text node and the value in a<b>(26px semibold); the active well is sunken and exposesaria-pressed.
<ChartStatWell active={series === "joins"} onClick={() => setSeries("joins")}> Joins <b>1,284</b></ChartStatWell>