Skip to content
edmi-ui

Chart

Recharts wrapped in a ChartContainer that maps series to --chart-N colors, with a shared tooltip and legend.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
NAV vs benchmark
Last 30 days
MAG4 outperformed SPYx by 6.1 ptsSep 1 to Oct 1
NAV vs benchmark
Last 30 days
MAG4 outperformed SPYx by 6.1 ptsSep 1 to Oct 1
NAV vs benchmark

Last 30 days

MAG4 outperformed SPYx by 6.1 pts Sep 1 to Oct 1
Terminal window
npx shadcn@latest add @edmi-ui/chart
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/chart
Terminal window
yarn dlx shadcn@latest add @edmi-ui/chart
Terminal window
bunx shadcn@latest add @edmi-ui/chart

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/chart
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/chart
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/chart
Terminal window
bunx shadcn-vue@latest add @edmi-ui/chart

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/chart.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/ui/chart.tsx
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,
};
registry/ui/chart/ChartContainer.vue
<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>
registry/ui/chart/ChartLegendContent.vue
<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>
registry/ui/chart/ChartStatWell.vue
<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>
registry/ui/chart/ChartStyle.vue
<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>
registry/ui/chart/ChartTooltipContent.vue
<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>
registry/ui/chart/index.ts
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";
registry/ui/chart/utils.ts
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 keys
const cache = new Map<string, string>();
// Convert object to a consistent string key
function 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;
};
}
src/lib/registry/ui/chart/chart-container.svelte
<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>
src/lib/registry/ui/chart/chart-stat-well.svelte
<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>
src/lib/registry/ui/chart/chart-style.svelte
<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}
src/lib/registry/ui/chart/chart-tooltip.svelte
<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>
src/lib/registry/ui/chart/chart-utils.ts
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})`;
}
src/lib/registry/ui/chart/index.ts
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,
};

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.

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 12px muted-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.

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.

Every variant from the Edmi Chart Kit, live in React, Vue and Svelte: Area · Bar · Line · Pie & donut · Radar · Radial · Tooltips.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Joins vs exits
This week
Joins vs exits
This week
Joins vs exits

This week

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Allocation
MAG4 weights
Allocation
MAG4 weights
Allocation

MAG4 weights

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.

  • Series without color/theme fall back to --chart-1…5 by order.
  • Tooltip is a solid --popover panel, 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-2 text, 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 exposes aria-pressed.
<ChartStatWell active={series === "joins"} onClick={() => setSeries("joins")}>
Joins
<b>1,284</b>
</ChartStatWell>