Skip to content
edmi-ui

Stat Tile

KPI tile with a mono value and delta badge, an optional segmented meter, and a stat strip for grouped numbers.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
AUM
$49,182
+37%vs last week
Daily active holders
10,291
+8%vs last week
Net flow
−$1,204
−4.2%vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes
AUM
$49,182
+37%
vs last week
Daily active holders
10,291
+8%
vs last week
Net flow
−$1,204
−4.2%
vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes
AUM
$49,182
+37% vs last week
Daily active holders
10,291
+8% vs last week
Net flow
−$1,204
−4.2% vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes
Terminal window
npx shadcn@latest add @edmi-ui/stat-tile
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/stat-tile
Terminal window
yarn dlx shadcn@latest add @edmi-ui/stat-tile
Terminal window
bunx shadcn@latest add @edmi-ui/stat-tile

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/stat-tile
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/stat-tile
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/stat-tile
Terminal window
bunx shadcn-vue@latest add @edmi-ui/stat-tile

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/stat-tile.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/stat-tile.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/stat-tile.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/stat-tile.json

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

import { StatTile } from "@/components/stat-tile/stat-tile";
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
<StatTile
label="AUM"
value="$49,182"
delta="+37%"
deltaLabel="vs last week"
/>
<StatTile
label="Daily active holders"
value="10,291"
meter={{
value: 23 / 30,
zones: [
{ upTo: 14 / 30, color: "var(--chart-1)" },
{ upTo: 24 / 30, color: "var(--chart-3)" },
{ upTo: 1, color: "var(--chart-5)" },
],
}}
delta="+8%"
deltaLabel="vs last week"
/>
{/* … */}
</div>
{/* … */}
</div>
<script setup lang="ts">
import { StatStrip, StatStripItem, StatTile } from "@/components/ui/stat-tile";
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-4">
<StatTile label="AUM" value="$49,182" delta="+37%" delta-label="vs last week" />
<StatTile
label="Daily active holders"
value="10,291"
delta="+8%"
delta-label="vs last week"
/>
<StatTile label="Net flow" value="−$1,204" delta="−4.2%" delta-label="vs last week" />
</div>
<StatStrip>
<StatStripItem value="412" label="holders" />
<StatStripItem value="$49.2K" label="AUM" />
<StatStripItem value="63%" label="activity off-hours" />
<StatStripItem value="14" label="indexes" />
</StatStrip>
</div>
</template>
<script lang="ts">
import {
StatStrip,
StatStripItem,
StatTile,
} from "$lib/components/ui/stat-tile";
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-4">
<StatTile label="AUM" value="$49,182" delta="+37%" deltaLabel="vs last week" />
<StatTile label="Daily active holders" value="10,291" delta="+8%" deltaLabel="vs last week" />
<StatTile label="Net flow" value="−$1,204" delta="−4.2%" deltaLabel="vs last week" />
</div>
<StatStrip>
<StatStripItem value="412" label="holders" />
<StatStripItem value="$49.2K" label="AUM" />
<StatStripItem value="63%" label="activity off-hours" />
<StatStripItem value="14" label="indexes" />
</StatStrip>
</div>
registry/blocks/stat-tile/stat-tile.tsx
import { cn } from "cn";
import type * as React from "react";
import { Badge } from "@/registry/edmi/ui/badge";
import { Card } from "@/registry/edmi/ui/card";
import type { Elevation } from "@/registry/edmi/ui/elevation";
const isDown = (s: string) => /^[-−–]/.test(s.trim());
type MeterZone = {
/** Upper bound of the zone, 0..1. */
upTo: number;
/** Any CSS color, e.g. `var(--chart-1)`. */
color: string;
};
const defaultZones: MeterZone[] = [
{ upTo: 0.5, color: "var(--chart-1)" },
{ upTo: 0.8, color: "var(--chart-3)" },
{ upTo: 1, color: "var(--chart-5)" },
];
// Segmented meter: `steps` flat bars, filled up to `value` (0..1), the rest dimmed.
function StatMeter({
className,
value,
steps = 30,
zones = defaultZones,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
value: number;
steps?: number;
zones?: MeterZone[];
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: <meter> cannot be styled as segments
<div
data-slot="stat-meter"
role="meter"
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={value}
className={cn("flex gap-0.5", className)}
{...props}
>
{Array.from({ length: steps }, (_, i) => {
const at = (i + 1) / steps;
const zone = zones.find((z) => at <= z.upTo) ?? zones[zones.length - 1];
return (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length static list
key={i}
className={cn("h-[7px] flex-1", at > value && "opacity-30")}
style={{ background: zone?.color }}
/>
);
})}
</div>
);
}
// KPI tile: label, mono value, delta badge (+ optional meter). Delta direction comes from its sign.
function StatTile({
className,
label,
value,
delta,
deltaLabel,
trend,
meter,
elevation,
children,
...props
}: Omit<React.ComponentProps<typeof Card>, "children" | "size"> & {
label: React.ReactNode;
value: React.ReactNode;
delta?: string;
deltaLabel?: React.ReactNode;
/** Overrides the sign-based direction of `delta`. */
trend?: "up" | "down";
/** Segmented meter under the value. */
meter?: React.ComponentProps<typeof StatMeter>;
/** ✦ depth of the tile (forwarded to Card). */
elevation?: Elevation;
children?: React.ReactNode;
}) {
const down = trend ? trend === "down" : delta ? isDown(delta) : false;
return (
<Card
data-slot="stat-tile"
elevation={elevation}
className={cn("w-60 gap-0 px-5 py-[18px]", className)}
{...props}
>
<div className="text-[13px] text-muted-foreground">{label}</div>
<div className="mt-1.5 font-mono text-[28px] leading-tight tracking-[-0.5px]">
{value}
</div>
{meter ? (
<StatMeter {...meter} className={cn("mt-2.5", meter.className)} />
) : null}
{delta ? (
<div className="mt-2.5 flex items-center gap-2">
<Badge
variant={down ? "destructive" : "success"}
shape="number"
className="px-[7px]"
>
{delta}
</Badge>
{deltaLabel ? (
<span className="text-xs text-muted-foreground">{deltaLabel}</span>
) : null}
</div>
) : null}
{children}
</Card>
);
}
// Row of numbers in one card; cells are divided by 1px borders.
function StatStrip({
className,
elevation,
...props
}: React.ComponentProps<typeof Card> & {
/** ✦ depth of the strip (forwarded to Card). */
elevation?: Elevation;
}) {
return (
<Card
data-slot="stat-strip"
elevation={elevation}
className={cn("flex-row gap-0 overflow-x-auto p-0", className)}
{...props}
/>
);
}
function StatStripItem({
className,
value,
label,
...props
}: Omit<React.ComponentProps<"div">, "children"> & {
value: React.ReactNode;
label: React.ReactNode;
}) {
return (
<div
data-slot="stat-strip-item"
className={cn(
"min-w-[140px] flex-1 border-border px-[22px] py-4 not-first:border-l",
className,
)}
{...props}
>
<div className="font-mono text-2xl">{value}</div>
<div className="mt-1 text-xs text-muted-foreground">{label}</div>
</div>
);
}
export type { MeterZone };
export { StatMeter, StatStrip, StatStripItem, StatTile };
registry/ui/stat-tile/StatMeter.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { MeterZone } from '.'
import { computed } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = withDefaults(defineProps<{
value: number
steps?: number
zones?: MeterZone[]
class?: HTMLAttributes['class']
}>(), {
steps: 30,
zones: () => [
{ upTo: 0.5, color: 'var(--chart-1)' },
{ upTo: 0.8, color: 'var(--chart-3)' },
{ upTo: 1, color: 'var(--chart-5)' },
],
})
// Segmented meter: `steps` flat bars, filled up to `value` (0..1), the rest dimmed.
const bars = computed(() =>
Array.from({ length: props.steps }, (_, i) => {
const at = (i + 1) / props.steps
const zone = props.zones.find(z => at <= z.upTo) ?? props.zones[props.zones.length - 1]
return { dim: at > props.value, color: zone?.color }
}),
)
</script>
<template>
<div
data-slot="stat-meter"
role="meter"
aria-valuemin="0"
aria-valuemax="1"
:aria-valuenow="value"
:class="cn('flex gap-0.5', props.class)"
>
<span
v-for="(b, i) in bars"
:key="i"
:class="cn('h-[7px] flex-1', b.dim && 'opacity-30')"
:style="{ background: b.color }"
/>
</div>
</template>
registry/ui/stat-tile/StatStrip.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { Card } from '@/registry/edmi/ui/card'
import type { Elevation } from '@/registry/edmi/ui/elevation'
const props = defineProps<{
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
class?: HTMLAttributes['class']
}>()
</script>
<template>
<!-- Row of numbers in one card; cells are divided by 1px borders. -->
<Card :elevation="elevation" data-slot="stat-strip" :class="cn('flex-row gap-0 overflow-x-auto p-0', props.class)">
<slot />
</Card>
</template>
registry/ui/stat-tile/StatStripItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
value?: string | number
label?: string
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="stat-strip-item"
:class="cn('min-w-[140px] flex-1 border-border px-[22px] py-4 not-first:border-l', props.class)"
>
<div class="font-mono text-2xl">
<slot name="value">{{ value }}</slot>
</div>
<div class="mt-1 text-xs text-muted-foreground">
<slot name="label">{{ label }}</slot>
</div>
</div>
</template>
registry/ui/stat-tile/StatTile.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { MeterZone } from '.'
import { computed } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { Badge } from '@/registry/edmi/ui/badge'
import { Card } from '@/registry/edmi/ui/card'
import type { Elevation } from '@/registry/edmi/ui/elevation'
import StatMeter from './StatMeter.vue'
const props = defineProps<{
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
label?: string
value?: string | number
delta?: string
/** Muted text next to the delta badge (or use the `delta-label` slot). */
deltaLabel?: string
/** Overrides the sign-based direction of `delta`. */
trend?: 'up' | 'down'
/** Segmented meter under the value. */
meter?: { value: number, steps?: number, zones?: MeterZone[], class?: HTMLAttributes['class'] }
class?: HTMLAttributes['class']
}>()
const down = computed(() =>
props.trend ? props.trend === 'down' : props.delta ? /^[-−–]/.test(props.delta.trim()) : false,
)
</script>
<template>
<!-- KPI tile: label, mono value, delta badge (+ optional meter). Delta direction comes from its sign. -->
<Card :elevation="elevation" data-slot="stat-tile" :class="cn('w-60 gap-0 px-5 py-[18px]', props.class)">
<div class="text-[13px] text-muted-foreground">
<slot name="label">{{ label }}</slot>
</div>
<div class="mt-1.5 font-mono text-[28px] leading-tight tracking-[-0.5px]">
<slot name="value">{{ value }}</slot>
</div>
<StatMeter
v-if="meter"
v-bind="meter"
:class="cn('mt-2.5', meter.class)"
/>
<div v-if="delta" class="mt-2.5 flex items-center gap-2">
<Badge :variant="down ? 'destructive' : 'success'" shape="number" class="px-[7px]">
{{ delta }}
</Badge>
<span v-if="deltaLabel || $slots['delta-label']" class="text-xs text-muted-foreground"><slot name="delta-label">{{ deltaLabel }}</slot></span>
</div>
<slot />
</Card>
</template>
registry/ui/stat-tile/index.ts
export type MeterZone = {
/** Upper bound of the zone, 0..1. */
upTo: number;
/** Any CSS color, e.g. `var(--chart-1)`. */
color: string;
};
export { default as StatMeter } from "./StatMeter.vue";
export { default as StatStrip } from "./StatStrip.vue";
export { default as StatStripItem } from "./StatStripItem.vue";
export { default as StatTile } from "./StatTile.vue";
src/lib/registry/ui/stat-tile/index.ts
export { default as StatMeter, type MeterZone } from "./stat-meter.svelte";
export { default as StatStrip } from "./stat-strip.svelte";
export { default as StatStripItem } from "./stat-strip-item.svelte";
export { default as StatTile } from "./stat-tile.svelte";
src/lib/registry/ui/stat-tile/stat-meter.svelte
<script lang="ts" module>
export type MeterZone = {
/** Upper bound of the zone, 0..1. */
upTo: number;
/** Any CSS color, e.g. `var(--chart-1)`. */
color: string;
};
const defaultZones: MeterZone[] = [
{ upTo: 0.5, color: "var(--chart-1)" },
{ upTo: 0.8, color: "var(--chart-3)" },
{ upTo: 1, color: "var(--chart-5)" },
];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
value,
steps = 30,
zones = defaultZones,
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "children">> & {
value: number;
steps?: number;
zones?: MeterZone[];
} = $props();
// Segmented meter: `steps` flat bars, filled up to `value` (0..1), the rest dimmed.
const bars = $derived(
Array.from({ length: steps }, (_, i) => {
const at = (i + 1) / steps;
const zone = zones.find((z) => at <= z.upTo) ?? zones[zones.length - 1];
return { dim: at > value, color: zone?.color };
})
);
</script>
<div
bind:this={ref}
data-slot="stat-meter"
role="meter"
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={value}
class={cn("flex gap-0.5", className)}
{...restProps}
>
{#each bars as b, i (i)}
<span class={cn("h-[7px] flex-1", b.dim && "opacity-30")} style:background={b.color}></span>
{/each}
</div>
src/lib/registry/ui/stat-tile/stat-strip-item.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
value,
label,
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "children">> & {
value: string | number | Snippet;
label: string | Snippet;
} = $props();
</script>
<div
bind:this={ref}
data-slot="stat-strip-item"
class={cn("min-w-[140px] flex-1 border-border px-[22px] py-4 not-first:border-l", className)}
{...restProps}
>
<div class="font-mono text-2xl">
{#if typeof value === "function"}{@render value()}{:else}{value}{/if}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{#if typeof label === "function"}{@render label()}{:else}{label}{/if}
</div>
</div>
src/lib/registry/ui/stat-tile/stat-strip.svelte
<script lang="ts">
import { Card } from "$lib/registry/ui/card/index.js";
import { cn } from "$lib/utils.js";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
elevation = "auto",
children,
...restProps
}: Omit<ComponentProps<typeof Card>, "size"> & {
/** ✦ depth of the strip (forwarded to Card). */
elevation?: Elevation;
} = $props();
</script>
<!-- Row of numbers in one card; cells are divided by 1px borders. -->
<Card bind:ref data-slot="stat-strip" {elevation} class={cn("flex-row gap-0 overflow-x-auto p-0", className)} {...restProps}>
{@render children?.()}
</Card>
src/lib/registry/ui/stat-tile/stat-tile.svelte
<script lang="ts">
import { Badge } from "$lib/registry/ui/badge/index.js";
import { Card } from "$lib/registry/ui/card/index.js";
import { cn } from "$lib/utils.js";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import type { ComponentProps, Snippet } from "svelte";
import StatMeter from "./stat-meter.svelte";
let {
ref = $bindable(null),
class: className,
label,
value,
delta,
deltaLabel,
trend,
meter,
elevation = "auto",
children,
...restProps
}: Omit<ComponentProps<typeof Card>, "children" | "size"> & {
label: Snippet | string;
value: Snippet | string | number;
delta?: string;
deltaLabel?: string | Snippet;
/** Overrides the sign-based direction of `delta`. */
trend?: "up" | "down";
/** Segmented meter under the value. */
meter?: ComponentProps<typeof StatMeter>;
/** ✦ depth of the tile (forwarded to Card). */
elevation?: Elevation;
children?: Snippet;
} = $props();
const down = $derived(trend ? trend === "down" : delta ? /^[-−–]/.test(delta.trim()) : false);
</script>
<!-- KPI tile: label, mono value, delta badge (+ optional meter). Delta direction comes from its sign. -->
<Card bind:ref data-slot="stat-tile" {elevation} class={cn("w-60 gap-0 px-5 py-[18px]", className)} {...restProps}>
<div class="text-[13px] text-muted-foreground">
{#if typeof label === "function"}{@render label()}{:else}{label}{/if}
</div>
<div class="mt-1.5 font-mono text-[28px] leading-tight tracking-[-0.5px]">
{#if typeof value === "function"}{@render value()}{:else}{value}{/if}
</div>
{#if meter}
<StatMeter {...meter} class={cn("mt-2.5", meter.class)} />
{/if}
{#if delta}
<div class="mt-2.5 flex items-center gap-2">
<Badge variant={down ? "destructive" : "success"} shape="number" class="px-[7px]">{delta}</Badge>
{#if deltaLabel}
<span class="text-xs text-muted-foreground">
{#if typeof deltaLabel === "function"}{@render deltaLabel()}{:else}{deltaLabel}{/if}
</span>
{/if}
</div>
{/if}
{@render children?.()}
</Card>

See the Elevation guide for the levels, roles and layered mode.

Flat by default. elevation is forwarded to the card: sunken (-1) a soft inset well, raised (+1) the bevel, floating (+2) the bevel plus one soft drop. In a layered ElevationProvider the card resolves to its role default (raised).

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

AUM
$49,182
+37%vs last week
Daily active holders
10,291
+8%vs last week
Net flow
−$1,204
−4.2%vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Raised (+1)

AUM
$49,182
+37%vs last week
Daily active holders
10,291
+8%vs last week
Net flow
−$1,204
−4.2%vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Floating (+2)

AUM
$49,182
+37%vs last week
Daily active holders
10,291
+8%vs last week
Net flow
−$1,204
−4.2%vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Flat (0)

AUM
$49,182
+37%
vs last week
Daily active holders
10,291
+8%
vs last week
Net flow
−$1,204
−4.2%
vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Raised (+1)

AUM
$49,182
+37%
vs last week
Daily active holders
10,291
+8%
vs last week
Net flow
−$1,204
−4.2%
vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Floating (+2)

AUM
$49,182
+37%
vs last week
Daily active holders
10,291
+8%
vs last week
Net flow
−$1,204
−4.2%
vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Flat (0)

AUM
$49,182
+37% vs last week
Daily active holders
10,291
+8% vs last week
Net flow
−$1,204
−4.2% vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Raised (+1)

AUM
$49,182
+37% vs last week
Daily active holders
10,291
+8% vs last week
Net flow
−$1,204
−4.2% vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes

Floating (+2)

AUM
$49,182
+37% vs last week
Daily active holders
10,291
+8% vs last week
Net flow
−$1,204
−4.2% vs last week
412
holders
$49.2K
AUM
63%
activity off-hours
14
indexes
Part Prop Type Default
StatTile label / value ReactNode (Vue slots label / value, Svelte string | Snippet) required
delta string (sign sets success/destructive badge) none
deltaLabel ReactNode (Vue delta-label slot, Svelte string | Snippet) none
trend "up" | "down" (overrides the sign) auto
meter StatMeter props none
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
StatStrip elevation forwarded to the card "auto"
StatStripItem label / value ReactNode (Vue slots label / value, Svelte string | number | Snippet) required
StatMeter value number 0..1 required
steps number 30
zones { upTo: number; color: string }[] green / amber / red
  • StatMeter and StatStrip / StatStripItem are separate exports of the same block.