@hozo/canvas
v0.4.0
Published
A declarative Canvas scene for Hozo, rendered by Canvas 2D on Web and Skia on React Native.
Maintainers
Readme
@hozo/canvas
Declarative retained-mode Canvas scenes for Hozo. The same scene is drawn with
the browser's built-in Canvas 2D API on Web and @shopify/react-native-skia on
React Native.
import { Canvas } from '@hozo/canvas'
export function Sparkline() {
return (
<Canvas
width={320}
height={120}
viewBox={[0, 0, 100, 40]}
accessibilityLabel="Revenue increased over the last six months"
>
<Canvas.Path
path="M 0 36 L 20 30 L 40 32 L 60 18 L 80 20 L 100 4"
stroke="#2563eb"
strokeWidth={2}
fill="none"
/>
</Canvas>
)
}Indexed triangle meshes avoid turning already-projected geometry into SVG path
strings. Consecutive vertex triples are used when indices is omitted; an
incomplete or invalid triple is skipped as one unit on both renderers.
<Canvas.TriangleMesh
vertices={[
{ x: 10, y: 10 },
{ x: 90, y: 10 },
{ x: 50, y: 70 },
]}
colors={[
{ r: 1, g: 0, b: 0 },
{ r: 0, g: 1, b: 0 },
{ r: 0, g: 0, b: 1 },
]}
/>Vertex colours use normalized sRGB channels with optional per-vertex alpha and
replace the uniform fill.
Skia interpolates them through Vertices; Canvas 2D composes three bounded
barycentric ramps per face, avoiding contrast-dependent tessellation.
Meshes can instead map an image with one top-left texture coordinate per
vertex, where one unit spans one image tile. Web loads a URL; Native accepts
the same URI form or a local asset ID. Sampling clamps at image edges by
default; wrap: 'repeat' tiles on both axes, wrap: 'mirror' alternates reflected
tiles, while wrapX and wrapY can
override either axis independently. Repeated axes permit coordinates outside
0..1. intensity and tint: { r, g, b } multiply linear RGB while preserving alpha.
Web caches the processed image (at most eight colour variants per decoded source,
fewer for large images),
requiring same-origin or CORS-enabled images for non-identity modulation.
Native uses Skia colour filters. All modes use
affine interpolation per triangle. Textures do not yet combine with vertex
colours or provide perspective-correct sampling.
<Canvas.TriangleMesh
vertices={[
{ x: 10, y: 10 },
{ x: 90, y: 10 },
{ x: 50, y: 70 },
]}
texture={{
source: '/checkerboard.png',
coordinates: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0.5, y: 1 },
],
filter: 'linear',
}}
/>Interaction
Every Canvas leaf supports portable onPress and destination-bearing href.
The same topmost shape must contain both the pointer/touch start and release;
dragging away cancels activation. Non-interactive drawing above a target does
not block it.
<Canvas.Rect
x={40}
y={10}
width={20}
height={30}
onPress={({ point, surfacePoint }) => {
// point uses viewBox/scene coordinates; surfacePoint uses CSS pixels or
// React Native layout points and is useful for positioning a tooltip.
selectBar(point.x, surfacePoint.x)
}}
/>An href uses the same installed @hozo/navigation adapter as Link,
Button href, and Pressable href. On Web, Ctrl/Command-click, Shift-click,
and a middle-button press open a new browsing context without entering the
client router. A named destination also produces a real anchor in the hidden
accessibility layer; an action produces a button. Native exposes the matching
link or button role.
<Canvas.Rect
width={40}
height={20}
href="/orders/42"
replace
accessibilityLabel="Open order 42"
/>onPress runs before navigation. It receives the familiar ctrlKey,
metaKey, shiftKey, altKey, and button fields. Call preventDefault()
to retain an activation for local interaction—for example, Ctrl/Command-click
or Shift-click selection in a Canvas-drawn table—without following href.
<Canvas.Rect
width={40}
height={20}
href="/orders/42"
accessibilityLabel="Open order 42"
onPress={(event) => {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
toggleSelectedOrder(42)
}
}}
/>Hit testing follows reverse paint order, viewBox, fit, nested group
translation/rotation/scale/origin, and rectangle clips. It uses the closed
shape's geometry rather than inspecting painted pixels. Changing only an event
handler or disabled updates a separate registry and does not redraw the
scene.
Line hit testing follows its painted stroke and cap. Path containment and text measurement are answered by each platform's renderer so they can participate without a second geometry or font implementation.
Canvas pixels cannot themselves provide keyboard or screen-reader interaction.
A named interactive shape therefore gets a semantic control in a visually
hidden sibling layer. An unnamed interactive shape remains pointer-only and
reports a warning; give it an accessibilityLabel or provide equivalent
visible controls outside the Canvas.
Canvas pixels are not semantic content. Every root must therefore either have
an accessibilityLabel, provide an accessibleFallback (for example a data
table), or opt out explicitly with decorative.
These are distinct accessibility modes. A label-only Canvas is exposed as one
named image. When accessibleFallback is present, the pixel surface is hidden
from assistive technology and the fallback is rendered as a visually hidden
semantic sibling; it is not nested under an image role that would erase the
fallback's table, list, or heading semantics. A decorative Canvas and its
drawing surface are hidden completely.
Build a fallback from the source data rather than trying to reconstruct it from rectangles and paths. It should be informational rather than interactive: a visually hidden button or link would create an invisible keyboard or screen reader focus target.
<Canvas
width={320}
height={120}
accessibilityLabel="Revenue by quarter"
accessibleFallback={
<table>
<caption>Revenue by quarter</caption>
<tbody>{/* rows made from the same series as the chart */}</tbody>
</table>
}
>
{/* visual marks */}
</Canvas>Skia is not installed by this package, so a Web-only installation does not download its native binaries. Add it only to a React Native app:
npx expo install @shopify/react-native-skiaThe Native entry imports Skia directly and will fail at bundle time with a missing-module error until it is installed. It remains an app-level dependency so Web-only Hozo installations stay small.
Part of Hozo, a universal UI framework for React Native, compiled by Rust, with native semantics on every platform, first-class accessibility, and diagnostics for humans and AI agents alike. Most applications install @hozo/core and one build integration; see Getting started. Source and issues: github.com/iray-tno/hozo.
