Building React Quill Canvases
PostHogOfficialPublished by PostHog
- Skill
- Guidelines
- Data and AI
- MIT
- Low risk
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides the technical requirements and design patterns for building native-looking PostHog canvases. It covers the single-component React architecture, the mandatory use of the Quill component library, theme-aware styling, and data-handling practices. It emphasizes using platform-provided modules for data fetching and state management while ensuring consistent loading, error, and empty states across the interface.
What it does
- Defines the single-component contract for React-based PostHog canvases.
- Mandates the use of Quill components for all UI elements to ensure native look and feel.
- Provides patterns for theme-aware styling using design tokens instead of hardcoded values.
- Outlines requirements for independent loading, error, and empty states for each data point.
- Specifies the integration of the PostHog date picker and platform-provided data modules.
- Establishes rules for safe external library usage and network capability declarations.
When to use it
- When building a custom canvas that requires a native PostHog appearance.
- When implementing dashboards, data boards, or interactive tools within the PostHog environment.
- When creating checklists or runbooks that need to persist state across reloads.
- When transitioning a canvas request from a conceptual build to a functional React implementation.
Advantages
- Ensures visual and functional consistency with the PostHog platform.
- Provides a structured approach to handling complex data-driven UI states.
- Reduces development friction by offering a validated starter scaffold.
- Promotes secure and performant canvas behavior through strict import and styling rules.
Usage examples
- “How do I implement a table in my PostHog canvas?”
- Use the Quill Table component with Card, TableHeader, TableRow, and TableBody, ensuring the Table is wrapped in a Card with the flush prop for proper alignment.
- “How should I handle loading states for multiple charts on one page?”
- Render a skeleton in each individual Card while its specific query is pending, ensuring that one slow query does not block the rendering of other components.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/posthog/building-react-quill-canvases/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip building-react-quill-canvases.zip -d .agents/skills
Documentation by PostHog
Building React + Quill canvases
The whole application is one React/TSX file (src/canvas.tsx in the source project). It must
export default a single React component that takes no props — the host mounts it. Do not import
react-dom or call createRoot.
Start from the working scaffold in references/starter-scaffold.md
on a first build: it already wires the date picker, theme tokens, per-query loading state (every
card fills in independently as its own data lands), and correct typed-node result reading. Keep
that wiring; replace the sample metrics and layout.
Keep its pattern of one component per card with editable() params, and follow the "Params" section of the building-canvases skill for each card you add.
Imports
Use React, Quill, Recharts, Lucide, and Day.js for the standard application shell. The platform also admits ten optional libraries for specialized work. Read references/platform-libraries.md before choosing one.
PostHog data comes through import { ph } from "@posthog/canvas-sdk" — a platform-provided
module, so it needs no dependencies entry. The same object exists as the window.ph global
(how existing canvases reach it); prefer the import in new code.
Other bare imports, dynamic import(), require(), <script> tags, and remote code fail
validation. Direct network requests and external images, fonts, media, or frames require an exact
HTTPS origin in capabilities.network.origins. They work only in the published canvas — the
edit-mode preview blocks direct network access regardless of declaration. Stylesheets from declared
origins are allowed; remote scripts remain blocked, so bundle code with the canvas.
Quill component rules
A PostHog data board must be built entirely from @posthog/quill components — never a native
control or a styled <div> standing in for one:
Dropdown/picker →
Select(never a native<select>); button →Button(never<button>); text field →Input/Textarea; checkbox →Checkbox; label →Label.Table →
Table(TableHeader>TableRow>TableHead, thenTableBody>TableRow>TableCell); panel →Card(CardHeader+CardTitle+CardContent); pill →Badge; titles →Heading; body →Text.A
Tableinside aCardneeds<Card size="sm" flush>around<Table size="sm" fullWidth>. Withoutflushthe card's padding insets the table, so its header and rows stop meeting the card's edges.flushdrops the card's section gap, bottom padding, and theCardContentinline padding, while the header keeps its own padding and divider. Matchingsize="sm"on both lines the table's edge columns up with the card's title.The only non-Quill tags allowed are plain layout
<div>s andrechartselements.Quill is built on Base UI: compose compound parts (
Select+SelectTrigger/SelectContent/SelectItem), use controlledvalue+onValueChange, and swap a part's element with therenderprop (e.g.<PopoverTrigger render={<Button …/>} />) instead of wrapping it.Quill components are already themed — never restyle one with Tailwind classes or inline
style; use theirvariant/sizeprops. Put layout utilities (flex,grid,gap-4,p-4) on your own wrapper<div>s.Buttons: default to
variant="outline";variant="primary"for the one main action only.
Styling and theme
Give the canvas's outermost element
min-h-screen(min-height: 100vh) so it fills the iframe viewport and grows past it as content demands. Do not useh-screenthere: a fixed viewport height caps a flex column, so tall children shrink and clip instead of scrolling. Do not useh-fullthere either: a published canvas's artifact shell gives itshtml,body, and#rootelements no explicit height, so a percentage root height collapses to content height.The root needs no
overflow-y-auto: once it grows past the viewport the iframe's own document scrolls.A
min-heightroot is not a definite height, soh-fullon a direct child still collapses to content height. Give an intermediate wrapper an explicit height (h-[280px]) when a child must fill a box.Style with Tailwind utilities and Quill components; reserve inline
stylefor genuinely dynamic runtime values (fixed sizes use arbitrary-value utilities likeh-[280px]).Write specific interface copy. Never use lorem ipsum or placeholder labels in a finished canvas.
The canvas follows the user's PostHog theme; a
.darkclass on the document root flips at runtime. Color only from the design-token utilities — surfacesbg-background bg-card bg-muted bg-primary bg-success bg-warning bg-info bg-destructive; texttext-foreground text-muted-foreground text-card-foreground; bordersborder-border. Never a hardcoded hex or light-only color.Status tokens invert the usual convention: the bare token (
bg-success) is a pale background fill and-foreground(text-success-foreground) is the strong readable color. Colored text or icons always use the-foregroundutility; a filled pill pairsbg-success text-success-foreground. Prefer the QuillBadge(variant="success"/"destructive") for deltas so you don't hand-pick.bg-secondary,text-secondary,bg-accent, andbg-popoverare not defined in the canvas — avoid them.Never declare a CSS variable with a platform token name (
--background,--border,--card,--chrome,--input,--muted,--primary,--fill-*), in a stylesheet or a<style>block. Quill sets those on every element, so your value never applies and text can turn unreadable. Prefix your own variables (--doc-muted); validation rejects the collision withplatform_token_redeclared.recharts strokes/fills use token CSS variables (
stroke="var(--primary)", grid/axes invar(--border)/var(--muted-foreground)).Some recharts defaults are hardcoded for a light theme and go wrong on a dark canvas. The platform stylesheet corrects them, so do not work around one in canvas code. Pie slice outlines are one: recharts strokes every sector white, and the stylesheet removes the outline. The antialiasing seam between stacked bar segments is another, and so is the default tooltip, which the stylesheet inverts against the page so its text always meets contrast. A bare
<Tooltip />is the right thing to write.Write Unicode glyphs (curly quotes, ellipsis, arrows, emoji) as literal characters in JSX —
\uXXXXescapes render verbatim in JSX text.
Loading, error, and empty states
Every data point renders a skeleton in its own Card while loading or refreshing: SkeletonText
(matching lines and text-size className) for text/number values, Skeleton for blocks/charts.
Render progressively — each query owns its loading state. The chrome (heading, date picker,
card frames with skeletons inside) renders immediately, every independent query fires
concurrently on mount, and each card swaps its skeleton for data the moment its own query
resolves, so a slow query only holds back its own card. Never drive the whole canvas off one
shared loading flag or a Promise.all across independent queries — that makes the fastest
metric wait for the slowest. Set each section's loading state true again on refresh; never show
a blank or a jumping layout. Content the first paint doesn't show (an inactive tab, a collapsed
section, a drill-down) defers its query until the user reveals it.
A failed query and an empty result are different states — never let one render as the other.
.catch on every ph.query/ph.loadInsight must set an error state that renders visibly (the
message plus a Retry button wired to the refresh nonce, as in the starter scaffold), not fall
through to zeros, an empty chart, or a "no data yet" message. A query that silently swallows its
error makes real breakage (a missing table, an auth failure, a bad query) look like missing data.
Reserve the empty state for a query that succeeded with no rows.
Data-product patterns
Treat these as starting shapes and adapt them to the request and available data.
Product dashboard
Build a live board from verified PostHog data, never a static mockup.
Start with a
Heading, then a responsive grid of compactCardKPIs, trend charts, and useful breakdown tables.Show a
Badgedelta for KPIs when a meaningful comparison period exists.Use a
LineChartfor time series and aBarChartfor discrete categories. Do not turn every result into a table.Give every KPI, chart, and table its own loading, empty, and error state.
Make every figure verifiable: an insight-backed card gets a "View in PostHog" affordance opening its saved insight (
ph.openExternalwith a URL minted bygenerate-app-url); an ad-hocph.querycard gets a "View query"DialogorCollapsibleshowing the exact query that ran — see "Verifiability" inquerying-canvas-data.
Web analytics board
Use this shape when the request or legacy requested pattern says web-analytics:
Show Visitors, Page views, Sessions, Session duration, and Bounce rate as KPI cards, with deltas against the previous equal-length period.
Plot unique visitors over time, with a second line for the previous period when comparison data is available.
Follow with compact tables for top paths, traffic sources or channels, devices, and geography.
Prefix countries with flag emoji. Add retention or active-hours views only when the available data makes them useful.
Use the web analytics query kinds described in querying-canvas-data; do not recreate bounce rate,
sessionization, attribution, or unique visitors in HogQL. Format large values for display, such as
236K, while preserving the raw value for calculations and accessible labels.
Interactive explorer
Use controlled Quill inputs for each dimension, event, or date choice. Keep result sets small and refresh every dependent query when a control changes.
Checklist or runbook
For a checklist, QA runbook, launch plan, onboarding sequence, or any list of steps people work
through and tick off, start from the complete, validated project in
references/checklist-example.md. Its load-bearing parts — the
typed content module separate from the component, one shared ph.state key per step, the
debounced ref-alongside-state update path, an expected outcome on every step, and visible
load/save failure states — are what break when improvised. Keep them; replace the content.
Date window
A data board owns its own date control — render Quill's DateTimePicker (never a custom Select or
native date input) inside a Popover whose trigger is a Quill Button. PopoverContent gets
exactly className="w-auto p-0" and nothing is added to DateTimePicker beyond
value/onApply/onCancel (it self-sizes; don't pass compact or widths). Re-run every query
when the window changes — see the querying-canvas-data skill for feeding it into dateRange.
State and actions
Persisting values across reloads (ph.state, with user/shared scopes) and writing into PostHog
from a button (ph.actions.invoke, e.g. filing a task or an annotation, or ph.tools.call, e.g.
changing a feature flag rollout) have their own API contracts, capability declarations, and
gesture rules — read the "Runtime memory", "PostHog writes" and "PostHog MCP tools" sections of
the querying-canvas-data skill before using them. Wire actions to a
Button onClick that disables itself while the call is in flight, and render the returned
error (they are real PostHog writes, throttled server-side).
Files
- ATTRIBUTION.mdAdded by AutoGineer877 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md12.0 KiB
- references/checklist-example.md18.0 KiB
- references/platform-libraries.md3.3 KiB
- references/starter-scaffold.md10.7 KiB
references/starter-scaffold.md
Canvas starter scaffold
A known-good baseline for a React + Quill data canvas. It already wires the pieces that are easy
to get wrong — the date picker (self-sizing, no compact), theme-aware tokens, per-query loading
state so every card fills in the moment its own data lands, reading a typed-node result correctly,
the "View query" verification dialog every ad-hoc data card must carry, and cards that expose their titles, colors and sizes as params with editable(). Start from it on a
first build: keep the wiring, replace the sample metrics and the layout with what the user asked
for. Ideally swap the inline ph.query typed nodes for saved insights loaded with
ph.loadInsight(shortId, { dateRange }) (see the querying-canvas-data skill) — then replace the
"View query" dialog with a "View in PostHog" button calling ph.openExternal(insightUrl), the URL
minted at authoring time by the generate-app-url MCP tool (/insights/{id}), so viewers verify
the numbers on the real insight with their own permissions applied.
The load-bearing pattern is useCanvasQuery: one instance per query, each owning its
{ loading, error, data }. All queries start concurrently on mount and each card renders as soon
as its own result arrives — a slow query only holds back its own card. Keep that shape as you add
metrics; never collapse the queries behind one shared loading flag or a Promise.all.
import React, { useEffect, useState } from 'react'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
DateTimePicker,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Heading,
Popover,
PopoverContent,
PopoverTrigger,
quickRanges,
SkeletonText,
} from '@posthog/quill'
import { RefreshCw } from 'lucide-react'
import { editable, ph } from '@posthog/canvas-sdk'
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
// One builder per query feeds both the ph.query call and its "View query"
// dialog, so the query a viewer inspects is exactly the one that ran. Typed
// query nodes are computed by PostHog's own runner so the numbers match the
// UI exactly. `event: null` = all events (works on any project).
const totalEventsQuery = (dateRange) => ({
kind: 'TrendsQuery',
series: [{ kind: 'EventsNode', event: null, name: 'All events', math: 'total' }],
dateRange,
})
// BoldNumber makes the runner compute uniques across the whole period;
// summing the per-day values (`count`) would recount anyone active on
// several days.
const uniqueUsersQuery = (dateRange) => ({
kind: 'TrendsQuery',
series: [{ kind: 'EventsNode', event: null, name: 'Unique users', math: 'dau' }],
trendsFilter: { display: 'BoldNumber' },
dateRange,
})
// One instance per query. Each section owns its own { loading, error, data }
// and renders the moment ITS result lands — never share one loading flag
// across queries or gate the canvas on Promise.all: that makes the fastest
// card wait for the slowest query.
function useCanvasQuery(runQuery, deps) {
const [state, setState] = useState({ loading: true, error: null, data: null })
useEffect(() => {
let cancelled = false
setState({ loading: true, error: null, data: null })
runQuery()
.then((data) => {
if (cancelled) return
setState({ loading: false, error: null, data })
})
.catch((err) => {
if (cancelled) return
// A failed query must LOOK failed — falling through to zeros or an
// empty chart reads as "no data" and hides real breakage.
setState({ loading: false, error: String(err?.message ?? err), data: null })
})
return () => {
cancelled = true
}
}, deps)
return state
}
function CardError({ message, onRetry }) {
return (
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-destructive">Couldn't load: {message}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
)
}
// Every ad-hoc ph.query card carries a verification affordance showing the
// exact query that ran. A card backed by a saved insight instead renders a
// "View in PostHog" Button calling ph.openExternal(insightUrl) — the URL
// minted by the generate-app-url MCP tool, never hand-built.
function ViewQueryDialog({ query }) {
return (
<Dialog>
<DialogTrigger
render={
<Button variant="outline" size="sm">
View query
</Button>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Query behind this figure</DialogTitle>
</DialogHeader>
<div className="max-h-80 overflow-auto whitespace-pre-wrap rounded bg-muted p-3 font-mono text-xs">
{JSON.stringify(query, null, 2)}
</div>
</DialogContent>
</Dialog>
)
}
function NumberCard(props) {
const { title = 'Total', state, onRetry, query } = props
return (
<Card size="sm" {...editable('NumberCard', props, { title: { type: 'text', label: 'Title', default: 'Total' } })}>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle>{title}</CardTitle>
{query ? <ViewQueryDialog query={query} /> : null}
</div>
</CardHeader>
<CardContent>
{state.loading ? (
<SkeletonText lines={1} className="text-3xl" />
) : state.error ? (
<CardError message={state.error} onRetry={onRetry} />
) : (
<Heading size="2xl">{state.data.toLocaleString()}</Heading>
)}
</CardContent>
</Card>
)
}
function TrendCard(props) {
const { title = 'Over time', color = 'var(--primary)', height = 280, state, onRetry, query } = props
return (
<Card
size="sm"
{...editable('TrendCard', props, {
title: { type: 'text', label: 'Title', default: 'Over time' },
color: { type: 'color', label: 'Line color', default: 'var(--primary)' },
height: { type: 'number', label: 'Chart height', min: 160, max: 520, step: 20, default: 280 },
})}
>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle>{title}</CardTitle>
<ViewQueryDialog query={query} />
</div>
</CardHeader>
<CardContent>
{state.loading ? (
<SkeletonText lines={6} />
) : state.error ? (
<CardError message={state.error} onRetry={onRetry} />
) : (
<div className="w-full" style={{ height }}>
<ResponsiveContainer>
<LineChart data={state.data.series}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="day" stroke="var(--muted-foreground)" tick={{ fontSize: 12 }} />
<YAxis stroke="var(--muted-foreground)" tick={{ fontSize: 12 }} />
<Tooltip />
<Line type="monotone" dataKey="value" stroke={color} dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
)
}
export default function Canvas() {
const def = quickRanges.find((r) => r.name === 'Last 30 days') ?? quickRanges[0]
const [win, setWin] = useState({
start: def.rangeSetter(new Date()),
end: new Date(),
range: def,
})
const [open, setOpen] = useState(false)
// Refresh plumbing: bump this nonce to re-run every data effect on demand.
const [nonce, setNonce] = useState(0)
const retry = () => setNonce((n) => n + 1)
const dateRange = {
date_from: win.start.toISOString(),
date_to: win.end.toISOString(),
}
// Both queries fire concurrently on mount; the cards below fill in
// independently as each result arrives.
const events = useCanvasQuery(
() =>
ph.query(totalEventsQuery(dateRange)).then((res) => {
// Typed-node result: `results` is an array of SERIES OBJECTS, not rows.
const firstSeries = res.results[0] ?? {}
return {
total: firstSeries.count ?? 0,
series: (firstSeries.days ?? []).map((day, index) => ({
day,
value: firstSeries.data?.[index] ?? 0,
})),
}
}),
[win, nonce]
)
const visitors = useCanvasQuery(
() => ph.query(uniqueUsersQuery(dateRange)).then((res) => res.results[0]?.aggregated_value ?? 0),
[win, nonce]
)
const anyLoading = events.loading || visitors.loading
return (
// The canvas root fills the iframe viewport and grows past it as content
// demands, so tall sections scroll instead of shrinking. The
// chrome (heading, date picker, card frames) renders immediately — only
// the value inside each card waits, each for its own query.
<div className="flex min-h-screen flex-col gap-4 p-6">
<div className="flex items-center justify-between">
<Heading size="xl" className="mb-4">
Canvas
</Heading>
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<Button variant="outline">{win.range.name}</Button>} />
{/* PopoverContent needs w-auto p-0 so its default fixed width +
padding don't squeeze the self-sizing picker (which clips the
quick-range tabs). No other styles on it or the picker. */}
<PopoverContent className="w-auto p-0">
<DateTimePicker
value={win}
onApply={(v) => {
setWin(v)
setOpen(false)
}}
onCancel={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
<Button variant="outline" disabled={anyLoading} onClick={retry}>
<RefreshCw size={14} className={anyLoading ? 'animate-spin' : undefined} />
Refresh
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<NumberCard
title="Total events"
state={{ ...events, data: events.data?.total }}
onRetry={retry}
query={totalEventsQuery(dateRange)}
/>
<NumberCard title="Unique users" state={visitors} onRetry={retry} query={uniqueUsersQuery(dateRange)} />
</div>
<TrendCard
title="Events over time"
color="var(--primary)"
height={280}
state={events}
onRetry={retry}
query={totalEventsQuery(dateRange)}
/>
</div>
)
}Note: reach PostHog through import { ph } from "@posthog/canvas-sdk", a platform-provided module
that needs no dependencies entry. The host also injects the same object as the window.ph
global. Never initialize it, and do not import or initialize posthog-js.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Instructions that use the network
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- PostHog
- Publisher
- PostHog (Official)
- Source
- PostHog/skills · skills/omnibus/building-react-quill-canvases · commit a5d30e7
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by PostHog or PostHog.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download building-react-quill-canvases.zip