Skip to content

Instantly share code, notes, and snippets.

@thesephist
Created June 15, 2025 23:03
Show Gist options
  • Save thesephist/0d02d52dffb89c224f948f5c946bb593 to your computer and use it in GitHub Desktop.
Save thesephist/0d02d52dffb89c224f948f5c946bb593 to your computer and use it in GitHub Desktop.
react-email/chart
import { Column, Heading, Row } from '@react-email/components';
import React, { CSSProperties, Fragment } from 'react';
import { ResponsiveRow } from './ResponsiveRow';
export type ChartProps<T> = {
// Content
items: T[];
getKey: (item: T) => string;
getValue: (item: T) => number;
getLabel: (item: T) => React.ReactNode;
isItemHighlighted?: (item: T) => boolean;
// Layout
title: React.ReactNode;
minYLabels: number;
chartHeight: number;
};
/**
* Renders a simple bar chart using HTML tables in a way that renders reasonably
* well in most email clients.
*/
export function Chart<T>(props: ChartProps<T>) {
const { items, getKey, getValue, getLabel, isItemHighlighted, title, minYLabels, chartHeight } =
props;
const values = items.map(getValue);
const maxValue = Math.max(...values, 10);
const increment = getLargestIncrementWithAtLeastNLabels({
maxValue,
minNumLabels: minYLabels,
});
const yAxisMax = Math.ceil(maxValue / increment) * increment;
const yAxisTickCount = yAxisMax / increment;
const yAxisTicks = Array.from({ length: yAxisTickCount }, (_, i) => i * increment);
const chartBarValueLabelPadding = 20;
return (
<>
<Heading as="h3" className="text-base font-medium my-2">
{title}
</Heading>
<Row>
<Column
style={{
verticalAlign: 'top',
height: chartHeight,
paddingTop: chartBarValueLabelPadding + chartHeight / yAxisTicks.length / 2 - 2,
}}
>
{yAxisTicks.map((_tick, index) => (
<div
key={index}
style={{
height: `${chartHeight / yAxisTicks.length}px`,
textAlign: 'right',
}}
>
<table className="h-full w-full">
<tr className="h-full w-full">
<td className="h-full w-full text-xs text-gray-500 whitespace-nowrap">
{yAxisTicks[yAxisTicks.length - index - 1]} —
</td>
</tr>
</table>
</div>
))}
</Column>
<Column>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{
tableLayout: 'fixed',
height: chartHeight,
}}
>
<tr style={{ height: chartHeight }}>
{items.map((it, index) => {
const value = values[index];
const barHeight = value > 0 ? Math.max((value / maxValue) * chartHeight, 1) : 0;
return (
<td key={getKey(it)}>
<div
style={{
width: '70%',
height: chartBarValueLabelPadding + chartHeight,
margin: '0 auto',
}}
>
{/* Empty & label segment */}
<div
className="text-xs text-gray-500"
style={{
height:
chartHeight +
chartBarValueLabelPadding -
barHeight -
(value === 0 ? 1 : 0),
width: '100%',
backgroundColor: 'transparent',
textAlign: 'center',
}}
>
<table className="h-full w-full">
<tr className="h-full w-full">
<td
className="h-full w-full"
style={{
verticalAlign: 'bottom',
textAlign: 'center',
}}
>
{value}
</td>
</tr>
</table>
</div>
{/* Bar */}
<div
style={{
height: `${barHeight}px`,
minHeight: 1,
width: '100%',
backgroundColor: isItemHighlighted?.(it) ? '#b3121f' : '#AAAAAA',
borderRadius: '3px',
}}
></div>
</div>
</td>
);
})}
</tr>
</table>
<table className="table-fixed w-full">
<tr>
{items.map((it) => (
<td key={getKey(it)} align="center">
<div
className="text-xxs text-gray-500 whitespace-nowrap"
style={{ marginTop: '3px' }}
>
{getLabel(it)}
</div>
</td>
))}
</tr>
</table>
</Column>
</Row>
</>
);
}
export function getLargestIncrementWithAtLeastNLabels(args: {
maxValue: number;
minNumLabels: number;
}): number {
const { maxValue, minNumLabels } = args;
const choices = [1000, 500, 250, 100, 50, 25, 10, 5, 2, 1];
for (const choice of choices) {
const numLabels = Math.ceil(maxValue / choice);
if (numLabels >= minNumLabels) {
return choice;
}
}
return choices[0];
}
export function getPercentiles(values: number[], percentiles: number[]): number[] {
const sortedValues = values.sort((a, b) => a - b);
return percentiles.map((p) => sortedValues[Math.floor(p * sortedValues.length - 1)]);
}
export function computeBuckets<T>(args: {
items: T[];
numBuckets: number;
getValue: (item: T) => number;
displayValue: (value: number) => string;
}): {
count: number;
label: string;
isMode: boolean;
}[] {
const { items, numBuckets, getValue, displayValue } = args;
if (items.length === 0) {
return [];
}
const values = items.map(getValue);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
if (minValue === maxValue) {
return [
{
count: items.length,
label: displayValue(minValue),
isMode: true,
},
];
}
const bucketSize = (maxValue - minValue) / numBuckets;
const bucketRanges = Array.from({ length: numBuckets }, (_, i) => ({
min: minValue + i * bucketSize,
max: minValue + (i + 1) * bucketSize,
}));
const bucketCounts = bucketRanges.map((range, i) => ({
range,
count: values.filter(
(value) =>
value >= range.min &&
(i === bucketRanges.length - 1 ? value <= range.max : value < range.max),
).length,
}));
const maxCount = Math.max(...bucketCounts.map((b) => b.count));
const buckets = bucketCounts.map((bucket) => ({
count: bucket.count,
label: `< ${displayValue(bucket.range.max)}`,
isMode: bucket.count === maxCount,
}));
return buckets;
}
export type SegmentedChartProps<T, C extends string> = {
items: T[];
segments: C[];
getKey: (item: T) => string;
getValue: (item: T, segment: C) => number;
getLabel: (item: T, index: number) => React.ReactNode;
getSegmentLabel: (segment: C) => string;
getSegmentColor: (segment: C) => string;
title: React.ReactNode;
minYLabels: number;
chartHeight: number;
};
export function SegmentedChart<T, C extends string>(props: SegmentedChartProps<T, C>) {
const {
items,
segments,
getKey,
getValue,
getLabel,
getSegmentLabel,
getSegmentColor,
title,
minYLabels,
chartHeight,
} = props;
const totals = items.map((item) => {
const values = segments.map((segment) => getValue(item, segment));
return {
item,
total: values.reduce((a, b) => a + b, 0),
values,
};
});
const maxTotal = totals.reduce((a, b) => Math.max(a, b.total), 0);
const increment = getLargestIncrementWithAtLeastNLabels({
maxValue: maxTotal,
minNumLabels: minYLabels,
});
const yAxisMax = Math.ceil(maxTotal / increment) * increment;
const yAxisTickCount = yAxisMax / increment;
const yAxisTicks = Array.from({ length: yAxisTickCount }, (_, i) => i * increment);
const chartLegendPadding = 0;
const chartBarValueLabelPadding = 20;
const legendSquareStyles: CSSProperties = {
width: '12px',
height: '12px',
borderRadius: '2px',
display: 'inline-block',
verticalAlign: 'middle',
marginLeft: 4,
marginRight: 4,
};
return (
<>
<ResponsiveRow className="my-2">
<Column>
<Heading as="h3" className="text-base font-medium my-0">
{title}
</Heading>
</Column>
<Column align="right">
{segments.map((seg, index) => {
return (
<Fragment key={seg}>
{index > 0 && <div className="inline-block w-1" />}
<div
style={{
...legendSquareStyles,
backgroundColor: getSegmentColor(seg),
}}
></div>
<span className="text-xs text-gray-600">{getSegmentLabel(seg)}</span>
</Fragment>
);
})}
</Column>
</ResponsiveRow>
<Row>
<Column
style={{
verticalAlign: 'top',
height: chartHeight,
paddingTop:
chartLegendPadding +
chartBarValueLabelPadding +
chartHeight / yAxisTicks.length / 2 -
2,
}}
>
{yAxisTicks.map((_tick, index) => (
<div
key={index}
style={{
height: `${chartHeight / yAxisTicks.length}px`,
textAlign: 'right',
}}
>
<table className="h-full w-full">
<tr className="h-full w-full">
<td className="h-full w-full text-xs text-gray-500 whitespace-nowrap">
{yAxisTicks[yAxisTicks.length - index - 1]} —
</td>
</tr>
</table>
</div>
))}
</Column>
<Column>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{
tableLayout: 'fixed',
height: chartHeight,
}}
>
<tr style={{ height: chartHeight }}>
{items.map((it, index) => {
const zeroBarHeight = 2;
const barSegmentPadding = 2;
const heights: Partial<Record<C, number>> = {};
for (const seg of segments) {
heights[seg] = (getValue(it, seg) / yAxisMax) * chartHeight;
}
let visibleBarHeight = 0;
const visibleSegments = segments.filter((seg) => heights[seg]);
if (visibleSegments.length > 0) {
for (const seg of visibleSegments) {
visibleBarHeight += heights[seg] ?? 0;
}
visibleBarHeight += barSegmentPadding * (visibleSegments.length - 1);
} else {
visibleBarHeight = zeroBarHeight;
}
return (
<td key={getKey(it)}>
<div
style={{
width: '70%',
height: chartBarValueLabelPadding + chartHeight,
margin: '0 auto',
}}
>
{/* Empty & label segment */}
<div
className="text-xs text-gray-500"
style={{
height: chartHeight + chartBarValueLabelPadding - visibleBarHeight,
width: '100%',
backgroundColor: 'transparent',
textAlign: 'center',
}}
>
<table className="h-full w-full">
<tr className="h-full w-full">
<td
className="h-full w-full"
style={{
verticalAlign: 'bottom',
textAlign: 'center',
}}
>
{totals[index].total}
</td>
</tr>
</table>
</div>
{/* Bar segments */}
{segments.map((seg, index) => {
const isFirstVisibleSegment =
segments.findIndex((s) => heights[s]) === index;
const isLastVisibleSegment =
findLastIndex(segments, (s) => heights[s]) === index;
function getBorderRadius() {
if (isFirstVisibleSegment && isLastVisibleSegment) {
return '3px';
} else if (isFirstVisibleSegment) {
return '3px 3px 0 0';
} else if (isLastVisibleSegment) {
return '0 0 3px 3px';
} else {
return '0';
}
}
function getMarginTop() {
if (!isFirstVisibleSegment) {
return barSegmentPadding;
} else {
return 0;
}
}
function getMarginBottom() {
if (!isLastVisibleSegment) {
return barSegmentPadding;
} else {
return 0;
}
}
if (heights[seg]) {
return (
<div
key={seg}
style={{
height: heights[seg],
width: '100%',
backgroundColor: getSegmentColor(seg),
borderRadius: getBorderRadius(),
marginTop: getMarginTop(),
marginBottom: getMarginBottom(),
}}
></div>
);
}
return null;
})}
{/* Empty bar */}
{totals[index].total === 0 && (
<div
style={{
width: '100%',
backgroundColor: '#ddd',
borderRadius: 3,
height: visibleBarHeight,
}}
/>
)}
</div>
</td>
);
})}
</tr>
</table>
<table className="table-fixed w-full border-collapse">
<tr>
{items.map((it, index) => (
<td key={getKey(it)} align="center" valign="top" style={{ paddingTop: '6px' }}>
<div className="text-xxs text-gray-500 whitespace-nowrap">
{getLabel(it, index)}
</div>
</td>
))}
</tr>
</table>
</Column>
</Row>
</>
);
}
function findLastIndex<T>(array: T[], predicate: (item: T) => unknown): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) {
return i;
}
}
return -1;
}
import * as React from 'react';
import { Row } from '@react-email/components';
/**
* Wraps React Email's `Row` primitive with behavior that makes it turn into a
* vertically stacked list of `Column`s on small screens.
*
* See also <style> in {@link index.tsx} for accompanying CSS implementation.
*/
export function ResponsiveRow(props: {
className?: string;
fixed?: boolean;
leading?: 'tight' | 'normal';
style?: React.CSSProperties;
children: React.ReactNode;
}) {
const { className, fixed, children, leading = 'normal', style } = props;
return (
<Row
className={`responsive-row ${fixed ? 'table-fixed' : ''} ${className ?? ''} ${
leading === 'tight' ? 'tight' : ''
}`}
style={style}
>
{children}
</Row>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment