MDX/OxiGrid
Accumulator Visualization
Default
In Figure Wrapper
Init End Markers
Melody Pattern
Tie Example
With Labels
With Seq Buttons
oxi-grid.tsx
import { Fragment, type ComponentChildren } from 'preact';
import { useMemo } from 'preact/hooks';
/** Data for a single pad in the grid */
interface PadData {
/** Column index (0-based, left to right) */
col: number;
/** Row index (0-based, bottom to top) */
row: number;
/** CSS color string for the pad (e.g., 'rgb(0, 200, 80)') */
color?: string;
/** Short text label displayed on the pad */
label?: string;
}
/** Props for the OxiGrid component */
interface OxiGridProps {
/** Number of horizontal steps (columns). Default: 16 */
steps?: number;
/** Number of vertical rows. Default: 8 */
rows?: number;
/** Array of active pads with position, color, and optional label */
pads?: PadData[];
/** Highlighted playhead column index (0-based) */
playhead?: number;
/**
* Loop start marker column index (0-based).
* Pass a number for a single marker that spans all rows, or an array where
* each entry is the marker column for that row (array index = row index,
* 0-based from bottom). Use `undefined` within the array to skip a row.
*/
initStep?: number | Array<number | undefined>;
/**
* Loop end marker column index (0-based).
* Pass a number for a single marker that spans all rows, or an array where
* each entry is the marker column for that row (array index = row index,
* 0-based from bottom). Use `undefined` within the array to skip a row.
*/
endStep?: number | Array<number | undefined>;
/** Note labels displayed on the right side (bottom to top order) */
scaleLabels?: string[];
/** Single label spanning all rows on the right side, vertically centered */
sideLabel?: string;
/** Active sequence button numbers (1-8) shown in the left column */
seqButtons?: number[];
}
/** Props for the OxiGridFigure wrapper (adds caption to OxiGridProps) */
interface OxiGridFigureProps extends OxiGridProps {
/** Caption text displayed below the grid as figcaption */
caption?: string;
/** Remove bottom padding so consecutive figures appear connected */
connected?: boolean;
}
export type { PadData, OxiGridProps, OxiGridFigureProps };
const DEFAULT_STEPS = 16;
const DEFAULT_ROWS = 8;
/** OxiGrid color palette */
const COLOR = {
PAD_DEFAULT: 'rgb(50,50,50)',
MARKER: 'rgb(168,85,247)',
PLAYHEAD: 'rgba(255,255,255,0.7)',
PLAYHEAD_SHADOW: 'rgba(255,255,255,0.5)',
LABEL_DARK: 'rgb(20,20,20)',
LABEL_LIGHT: 'rgba(255,255,255,0.9)',
} as const;
function getPadKey(col: number, row: number) {
return `${col}-${row}`;
}
function buildPadMap(pads: PadData[]) {
const map = new Map<string, PadData>();
for (const pad of pads) {
map.set(getPadKey(pad.col, pad.row), pad);
}
return map;
}
const padCellClass =
'flex items-center justify-center aspect-square select-none font-mono text-[0.7em] leading-none transition-colors duration-150 rounded-[3px]';
const scaleLabelClass =
'flex items-center pl-[4px] select-none font-mono text-[0.5em] lg:text-[0.65em] text-zd-subtext whitespace-nowrap';
const seqButtonBaseClass =
'flex items-center justify-center aspect-square select-none font-mono leading-none rounded-[3px] text-[0.6em]';
const sideLabelClass =
'flex items-center justify-center select-none font-mono text-[0.5em] lg:text-[0.65em] text-zd-subtext whitespace-nowrap [writing-mode:vertical-rl]';
const seqButtonActiveClass = `${seqButtonBaseClass} bg-[rgb(59,130,246)] text-white`;
const seqButtonInactiveClass = `${seqButtonBaseClass} bg-[rgb(60,60,60)] text-[rgb(120,120,120)]`;
export const OxiGrid = ({
steps = DEFAULT_STEPS,
rows = DEFAULT_ROWS,
pads = [],
playhead,
initStep,
endStep,
scaleLabels,
sideLabel,
seqButtons,
}: OxiGridProps) => {
const padMap = useMemo(() => buildPadMap(pads), [pads]);
const seqButtonSet = useMemo(() => new Set(seqButtons ?? []), [seqButtons]);
const hasSeqButtons = seqButtons && seqButtons.length > 0;
const hasScaleLabels = scaleLabels && scaleLabels.length > 0;
const hasRightCol = hasScaleLabels || !!sideLabel;
// CSS Grid column template: optional seq btn + N step columns + optional label
// seq btn column is narrower (0.6fr), label column is auto-sized
const gridTemplateCols = [
hasSeqButtons ? '0.6fr' : '',
`repeat(${steps}, minmax(0, 1fr))`,
hasRightCol ? 'auto' : '',
]
.filter(Boolean)
.join(' ');
const gridRows = useMemo(() => {
const hasPlayhead = playhead !== undefined;
const initIsArr = Array.isArray(initStep);
const endIsArr = Array.isArray(endStep);
const hasMarkers = initStep !== undefined || endStep !== undefined;
// Bottom marker strip only makes sense when both markers span all rows.
// When either is an array (per-row), the markers live on the pads themselves.
const hasMarkerRow = hasMarkers && !initIsArr && !endIsArr;
const result: ComponentChildren[] = [];
// Calculate grid row/col positions for sideLabel spanning
const playheadRowOffset = hasPlayhead ? 1 : 0;
// Pad rows start after playhead row (1-indexed for CSS grid-row)
const padRowStart = 1 + playheadRowOffset;
const padRowEnd = padRowStart + rows;
// Right-side column number (1-indexed) for sideLabel
const rightCol = (hasSeqButtons ? 1 : 0) + steps + 1;
// Playhead row
if (hasPlayhead) {
result.push(
<Fragment key="playhead-row">
{hasSeqButtons && <div />}
{Array.from({ length: steps }, (_, c) => (
<div
key={`ph-${c}`}
className="rounded-[1px]"
style={{
aspectRatio: `${steps} / 1`,
backgroundColor: c === playhead ? COLOR.PLAYHEAD : 'transparent',
}}
/>
))}
{hasRightCol && !sideLabel && <div />}
</Fragment>,
);
}
// Side label: single cell spanning all pad rows, vertically centered
if (sideLabel) {
result.push(
<div
key="side-label"
className={sideLabelClass}
style={{
gridColumn: rightCol,
gridRow: `${padRowStart} / ${padRowEnd}`,
}}
>
{sideLabel}
</div>,
);
}
// Main pad rows (top to bottom = high to low pitch)
for (let r = rows - 1; r >= 0; r--) {
const rowCells: ComponentChildren[] = [];
const rowInit = initIsArr ? (initStep as Array<number | undefined>)[r] : initStep;
const rowEnd = endIsArr ? (endStep as Array<number | undefined>)[r] : endStep;
// Seq button cell
if (hasSeqButtons) {
const num = r + 1;
const isActive = seqButtonSet.has(num);
rowCells.push(
<div
key={`seq-${r}`}
className={isActive ? seqButtonActiveClass : seqButtonInactiveClass}
>
{num}
</div>,
);
}
// Pad cells
for (let c = 0; c < steps; c++) {
const pad = padMap.get(getPadKey(c, r));
const isPlayhead = hasPlayhead && c === playhead;
const isInitMarker = rowInit !== undefined && c === rowInit;
const isEndMarker = rowEnd !== undefined && c === rowEnd;
let bgColor: string = COLOR.PAD_DEFAULT;
if (pad?.color) {
bgColor = pad.color;
} else if (isInitMarker || isEndMarker) {
bgColor = COLOR.MARKER;
}
const boxShadow = isPlayhead ? `inset 0 0 0 2px ${COLOR.PLAYHEAD_SHADOW}` : undefined;
rowCells.push(
<div
key={`pad-${c}-${r}`}
className={padCellClass}
style={{
backgroundColor: bgColor,
boxShadow,
color: pad?.label
? isLightColor(bgColor)
? COLOR.LABEL_DARK
: COLOR.LABEL_LIGHT
: undefined,
}}
>
{pad?.label}
</div>,
);
}
// Scale label cell (per-row labels, not used with sideLabel)
if (hasScaleLabels) {
const label = scaleLabels[r] ?? '';
rowCells.push(
<div key={`label-${r}`} className={scaleLabelClass}>
{label}
</div>,
);
}
result.push(<Fragment key={`row-${r}`}>{rowCells}</Fragment>);
}
// Init/End marker row (only rendered when markers span all rows)
if (hasMarkerRow) {
result.push(
<Fragment key="marker-row">
{hasSeqButtons && <div />}
{Array.from({ length: steps }, (_, c) => {
const isInit = typeof initStep === 'number' && c === initStep;
const isEnd = typeof endStep === 'number' && c === endStep;
return (
<div
key={`mk-${c}`}
className="rounded-[1px]"
style={{
aspectRatio: `${steps} / 1`,
backgroundColor: isInit || isEnd ? COLOR.MARKER : 'transparent',
}}
/>
);
})}
{hasRightCol && !sideLabel && <div />}
</Fragment>,
);
}
return result;
}, [
rows,
steps,
padMap,
playhead,
initStep,
endStep,
hasSeqButtons,
hasScaleLabels,
hasRightCol,
seqButtonSet,
scaleLabels,
sideLabel,
]);
return (
<div
role="img"
aria-label="OXI ONE MKII sequencer grid"
className={
'grid w-full gap-[3px] p-[5px] lg:gap-[5px] lg:p-[8px] rounded-lg bg-[rgb(24,24,24)]'
}
style={{
gridTemplateColumns: gridTemplateCols,
}}
>
{gridRows}
</div>
);
};
function isLightColor(color: string): boolean {
const match = color.match(/\d+/g);
if (!match || match.length < 3) return false;
const [r, g, b] = match.map(Number);
return (r! * 299 + g! * 587 + b! * 114) / 1000 > 150;
}
oxi-grid-figure.tsx
import { OxiGrid, type OxiGridFigureProps } from './oxi-grid';
const captionClass = 'text-center text-zd-subtext text-sm mt-vgap-xs';
/**
* Responsive wrapper for OxiGrid in MDX articles.
* - screen < lg: width 100%
* - screen >= lg: centered, max 70%
* Caption is rendered outside the grid as a figcaption.
*/
const DEFAULT_STEPS = 16;
export const OxiGridFigure = ({ caption, connected, ...gridProps }: OxiGridFigureProps) => {
const steps = gridProps.steps ?? DEFAULT_STEPS;
// Scale grid width proportionally when fewer than 16 steps,
// so cells stay the same size as a 16-step grid
const gridMaxWidth = steps < DEFAULT_STEPS ? `${(steps / DEFAULT_STEPS) * 100}%` : undefined;
return (
<figure
className={`w-full lg:mx-auto lg:max-w-[70%] pt-vgap-sm ${connected ? 'pb-0' : 'pb-vgap-lg'}`}
aria-label={caption ? undefined : 'OXI ONE MKII grid diagram'}
>
<div style={gridMaxWidth ? { maxWidth: gridMaxWidth, marginInline: 'auto' } : undefined}>
<OxiGrid {...gridProps} />
</div>
{caption && <figcaption className={captionClass}>{caption}</figcaption>}
</figure>
);
};
oxi-grid.stories.tsx
import { OxiGrid } from './oxi-grid';
import { OxiGridFigure } from './oxi-grid-figure';
/**
* OxiGrid Component
*
* Renders an OXI ONE MKII-style 16x8 pad grid for use in MDX articles.
* Uses CSS Grid with 1fr columns for auto-expandable sizing.
* Supports pads with colors/labels, playhead, init/end markers,
* scale labels, and seq buttons.
*/
export const meta = {
title: 'MDX/OxiGrid',
};
/**
* Default empty 16x8 grid — fills available width
*/
export const Default = () => <OxiGrid />;
/**
* C minor melody with notes, playhead at step 5, and scale labels
*/
export const MelodyPattern = () => (
<OxiGrid
steps={16}
rows={8}
scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
playhead={4}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 1, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 2, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 3, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 4, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 6, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 7, row: 1, color: 'rgb(0, 200, 80)' },
{ col: 8, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 10, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 11, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 12, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 14, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 15, row: 7, color: 'rgb(0, 200, 80)' },
]}
/>
);
/**
* Tied notes example: steps 3-5 tied together (darker green = tie continuation)
*/
export const TieExample = () => (
<OxiGrid
steps={8}
rows={8}
scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 1, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 2, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 3, row: 4, color: 'rgb(0, 120, 50)' },
{ col: 4, row: 4, color: 'rgb(0, 120, 50)' },
{ col: 5, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 6, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 7, row: 0, color: 'rgb(0, 200, 80)' },
]}
/>
);
/**
* Init/End loop markers shown as purple indicators
*/
export const InitEndMarkers = () => (
<OxiGrid
steps={16}
rows={8}
initStep={2}
endStep={10}
scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 2, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 4, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 5, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 7, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 8, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 10, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 13, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 15, row: 1, color: 'rgb(0, 200, 80)' },
]}
/>
);
/**
* Accumulator visualization: orange pads show pitch changes over cycles
*/
export const AccumulatorVisualization = () => (
<OxiGrid
steps={16}
rows={8}
scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 1, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 2, row: 2, color: 'rgb(230, 140, 20)' },
{ col: 3, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 4, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 5, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 6, row: 3, color: 'rgb(230, 140, 20)' },
{ col: 7, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 8, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 9, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 10, row: 4, color: 'rgb(230, 140, 20)' },
{ col: 11, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 12, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 13, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 14, row: 3, color: 'rgb(230, 140, 20)' },
{ col: 15, row: 4, color: 'rgb(0, 200, 80)' },
]}
/>
);
/**
* Grid with seq buttons column showing active sequences
*/
export const WithSeqButtons = () => (
<OxiGrid
steps={16}
rows={8}
seqButtons={[1, 3]}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 4, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 8, row: 5, color: 'rgb(0, 200, 80)' },
{ col: 12, row: 2, color: 'rgb(0, 200, 80)' },
]}
/>
);
/**
* OxiGrid wrapped in OxiGridFigure for responsive article layout.
* Caption is rendered as figcaption outside the grid.
*/
export const InFigureWrapper = () => (
<div style={{ width: 800, border: '1px dashed rgba(255,255,255,0.2)' }}>
<OxiGridFigure
steps={16}
rows={8}
scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
playhead={4}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 1, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 2, row: 3, color: 'rgb(0, 200, 80)' },
{ col: 3, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 4, row: 2, color: 'rgb(0, 200, 80)' },
{ col: 8, row: 4, color: 'rgb(0, 200, 80)' },
{ col: 12, row: 0, color: 'rgb(0, 200, 80)' },
{ col: 15, row: 7, color: 'rgb(0, 200, 80)' },
]}
caption="Wrapped in OxiGridFigure (responsive container)"
/>
</div>
);
/**
* Grid with labels on pads
*/
export const WithLabels = () => (
<OxiGrid
steps={16}
rows={8}
pads={[
{ col: 0, row: 0, color: 'rgb(0, 200, 80)', label: 'C' },
{ col: 1, row: 1, color: 'rgb(0, 200, 80)', label: 'D' },
{ col: 2, row: 2, color: 'rgb(0, 200, 80)', label: 'E' },
{ col: 3, row: 3, color: 'rgb(0, 200, 80)', label: 'F' },
{ col: 4, row: 2, color: 'rgb(59, 130, 246)', label: 'E' },
{ col: 5, row: 1, color: 'rgb(59, 130, 246)', label: 'D' },
{ col: 6, row: 0, color: 'rgb(59, 130, 246)', label: 'C' },
]}
/>
);