Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
<p align="center">
<h1>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg" />
<img alt="React Native Canvas Kit" src="assets/logo.svg" width="112" height="112" />
<img alt="React Native Canvas Kit" src="assets/logo.svg" width="48" height="48" align="left" />
</picture>
</p>
&nbsp;react-native-canvas-kit
</h1>

# react-native-canvas-kit
<p align="center">
<a href="https://www.npmjs.com/package/react-native-canvas-kit"><img alt="npm version" src="https://img.shields.io/npm/v/react-native-canvas-kit?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/react-native-canvas-kit"><img alt="license" src="https://img.shields.io/npm/l/react-native-canvas-kit?style=flat-square" /></a>
<a href="https://www.npmjs.com/package/react-native-canvas-kit"><img alt="types included" src="https://img.shields.io/badge/types-included-blue?style=flat-square" /></a>
<a href="https://expo.io/"><img alt="runs with expo" src="https://img.shields.io/badge/Runs%20with%20Expo-4630EB.svg?style=flat-square&logo=EXPO&labelColor=f3f3f3&logoColor=000" /></a>
</p>

<p align="center">
<video src="https://github.com/user-attachments/assets/762d8210-e6f8-4423-8027-859e4156b6b8" width="100%" controls muted autoplay loop></video>
Expand All @@ -21,6 +27,21 @@ experience from scratch.
> Full guides and API reference live in the
> [documentation site](https://adithyavis.github.io/react-native-canvas-kit/intro).

## Showcase

Popular app experiences rebuilt with Canvas Kit:

<p align="center">
<video src="https://github.com/user-attachments/assets/88cc1c85-a0bb-4416-b454-490c61a04aef" width="180" controls muted autoplay loop></video>
<video src="https://github.com/user-attachments/assets/e0caed23-7470-4fc7-8b77-029c77e8f8e5" width="180" controls muted autoplay loop></video>
<video src="https://github.com/user-attachments/assets/8256f8ed-023e-4c58-9722-dcc9729bae82" width="180" controls muted autoplay loop></video>
<video src="https://github.com/user-attachments/assets/afc7c51a-e421-4050-992e-df0cb6639beb" width="180" controls muted autoplay loop></video>
<video src="https://github.com/user-attachments/assets/b0c28f35-3d77-40a0-9cf3-f35bd7a52dcd" width="180" controls muted autoplay loop></video>
</p>
<p align="center">
<sub><b>Canva</b> &nbsp;&middot;&nbsp; <b>Instagram</b> &nbsp;&middot;&nbsp; <b>Snapseed</b> &nbsp;&middot;&nbsp; <b>DocuSign</b> &nbsp;&middot;&nbsp; <b>Google Pay</b></sub>
</p>

## What you get

- **Shapes**: `Rect`, `Circle`, `Ellipse`, `Line`, `RegularPolygon`, `Star`,
Expand Down
Binary file added assets/canva.mov
Binary file not shown.
Binary file added assets/docusign.mov
Binary file not shown.
Binary file added assets/gpay_scratch_card.mov
Binary file not shown.
Binary file added assets/instagram_crop.mov
Binary file not shown.
Binary file added assets/snapseed.mov
Binary file not shown.
193 changes: 193 additions & 0 deletions docs/docs/core-concepts/infinite-canvas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
---
sidebar_position: 5
title: Infinite Canvas
---

# Infinite Canvas

Set `infinite` on the [`Stage`](./stage.md) to turn it into a pannable,
zoomable scene: one-finger drag pans the whole scene, two-finger pinch zooms it,
and content can live anywhere in scene coordinates.

```tsx
<Stage width={width} height={height} infinite>
<Layer>{/* shapes at any coordinates */}</Layer>
</Stage>
```

Infinite mode is **not** a giant surface. The canvas stays the size of the
viewport; a scene transform (a pan offset and a zoom factor) is applied at the
root of the scene graph. Because every node's on-screen position is already
resolved by walking up to that root, hit-testing, [Portals](../portal/overview.md),
the [Transformer](../interactivity/transformer.md), and snapping all pan and
zoom for free.

## Gesture behavior

With `infinite`, gestures route to a shape when one is under the fingers, and to
the scene otherwise:

| Gesture | On a matching node | On empty space |
| ----------------- | --------------------------------- | --------------------------------- |
| One-finger drag | Drags the node (if `draggable`) | Pans the scene |
| Two-finger pinch | Scales the node (if `scalable`) | Zooms the scene |
| Two-finger rotate | Rotates the node (if `rotatable`) | Nothing — the scene never rotates |

So a note you can move and resize still declares `draggable scalable`; dragging
it moves the note, while dragging the background pans the board. The scene itself
only ever pans and zooms — it does not rotate.

## Zoom limits

Clamp how far the scene can zoom with `minZoom` / `maxZoom` (defaults `0.1` and
`10`):

```tsx
<Stage width={width} height={height} infinite minZoom={0.25} maxZoom={4} />
```

## Controlling the zoom

Attach a `ref` to drive the scene imperatively through the
[`StageHandle`](./stage.md):

```tsx
const stageRef = useRef<StageHandle>(null);

stageRef.current?.zoomIn(); // zoom in around the viewport center
stageRef.current?.zoomOut();
stageRef.current?.zoomTo(2, { x: 200, y: 300 }); // zoom to 2x around a focal point
stageRef.current?.panTo(x, y); // set the scene offset
stageRef.current?.centerOn(x, y, 0.7); // center a scene point at a given zoom
stageRef.current?.resetView(); // back to offset 0, zoom 1
const scene = stageRef.current?.getScene(); // { x, y, scale }
```

| Method | Description |
| ---------------------------------- | ---------------------------------------------------------------- |
| `getScene()` | Current scene as `{ x, y, scale }`. |
| `zoomTo(scale, focal?)` | Zoom to `scale` (clamped) around `focal` or the viewport center. |
| `zoomIn(step?)` / `zoomOut(step?)` | Multiply / divide the zoom by `step` (default `1.2`). |
| `panTo(x, y)` | Set the scene offset directly. |
| `centerOn(x, y, scale?)` | Center the scene point `(x, y)` in the viewport at `scale`. |
| `resetView()` | Reset to offset `0`, zoom `1`. |

Use `centerOn` on mount to open the view on your content instead of the
top-left origin:

```tsx
useEffect(() => {
stageRef.current?.centerOn(boardCenterX, boardCenterY, 0.7);
}, []);
```

### Observing zoom changes

Pass `onSceneChange` to react to zoom on the JS thread — for example, to render a
live zoom percentage. It fires on integer-percent zoom changes, so it does not
flood React during a pinch:

```tsx
const [zoom, setZoom] = useState(1);

<Stage
width={width}
height={height}
infinite
onSceneChange={(scene) => setZoom(scene.scale)}
>
{/* ... */}
</Stage>;

// elsewhere: <Text>{Math.round(zoom * 100)}%</Text>
```

## Reacting to the changes from children

`useSceneTransform()` exposes the live scene offset and zoom as shared values to
any component inside the `Stage`. It is the way to build scene-aware overlays —
a dot grid, rulers, a minimap — that stay crisp at every zoom without
re-rendering. Below, a grid keeps a constant on-screen dot spacing and size by
choosing them from the current zoom, entirely on the UI thread:

```tsx
import { useSceneTransform } from 'react-native-canvas-kit';
import { Path, Skia } from '@shopify/react-native-skia';
import { useDerivedValue } from 'react-native-reanimated';

function DotGrid() {
const scene = useSceneTransform();

const path = useDerivedValue(() => {
const p = Skia.Path.Make();
if (!scene) return p;
const scale = scene.sceneScaleSV.value.x || 1;
const offset = scene.sceneOffsetSV.value;

const left = -offset.x / scale;
const top = -offset.y / scale;
const right = (scene.width - offset.x) / scale;
const bottom = (scene.height - offset.y) / scale;

let spacing = 32;
while (spacing * scale < 24) spacing *= 2;
while (spacing * scale >= 48) spacing /= 2;

const startX = Math.floor(left / spacing) * spacing;
const startY = Math.floor(top / spacing) * spacing;
for (let x = startX; x <= right; x += spacing) {
for (let y = startY; y <= bottom; y += spacing) {
p.addCircle(x, y, radius);
}
}
return p;
}, [scene]);

return <Path path={path} color="#00000030" style="fill" />;
}
```

`useSceneTransform()` returns `{ sceneOffsetSV, sceneScaleSV, width, height }`,
or `null` outside a `Stage`. The scene scale is a `Vector2d`; the zoom is
uniform, so read `.x`.

## Rendering large scenes

The scene transform itself is cheap — it is one group transform on the GPU. What
costs, at scale, is **the number of nodes**. Every canvas-kit node (a `Rect`,
`Circle`, `Group`, …) is registered for hit-testing and carries its own
transform, so a scene with thousands of them pays for all of them every frame,
even the off-screen ones (Skia quick-rejects their _fill_, but the node tree is
still walked).

A few guidelines:

- **Interactive objects** (things you tap, drag, or transform) are the right use
for canvas-kit nodes. Keep this to what the user actually manipulates.
- **Bulk decoration** (grids, backgrounds, particle fields) should be a single
Skia primitive, like the `DotGrid` above — one node whose drawing is computed
in a worklet, not one node per dot.
- **Large fields of static content** are cheapest drawn without per-item nodes:
render them as raw `@shopify/react-native-skia` components (`RoundedRect`,
`Text`, …) in a `React.memo`'d subtree so they mount once and are not
reconciled on pan/zoom, or record them into a single `<Picture>`. Promote only
the item currently being edited to a live, interactive node.

### Culling to the viewport

For very large scenes, `useRenderBounds(margin)` gives you the visible scene
rectangle (expanded by `margin`) as a **shared value**, updated on the UI thread
with hysteresis so it does not fire every frame. Consume it in a worklet to draw
only what is on screen, and use `rectIntersectsBounds` to test each item:

```tsx
import { useRenderBounds, rectIntersectsBounds } from 'react-native-canvas-kit';

const boundsSV = useRenderBounds(300); // SharedValue<VisibleBounds | null>
```

Because it is a shared value, `useRenderBounds` is for **worklet-driven**
rendering (a `<Picture>` or a Skia drawing). Mounting or unmounting canvas-kit
_nodes_ based on visibility is a React operation and must be driven from state,
so windowing a node list re-renders when the visible set changes — expected, and
the same model as `FlatList`.
10 changes: 10 additions & 0 deletions docs/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ const config: Config = {
colorMode: {
respectPrefersColorScheme: true,
},
algolia: {
appId: 'U9PLQTMVZT',
apiKey: 'fb01f09f8f35cc449627ffcdfe315111',
indexName: 'React Native Canvas Kit',
contextualSearch: false,
},
navbar: {
title: 'Canvas Kit',
logo: {
Expand All @@ -72,6 +78,10 @@ const config: Config = {
position: 'left',
label: 'Docs',
},
{
type: 'search',
position: 'right',
},
{
type: 'docsVersionDropdown',
position: 'right',
Expand Down
6 changes: 5 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
"typescript": "~5.6.2"
},
"browserslist": {
"production": [">0.5%", "not dead", "not op_mini all"],
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
Expand Down
62 changes: 42 additions & 20 deletions docs/src/components/HomepageFeatures/styles.module.css
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
/**
* Homepage feature-grid styling.
*/

.features {
padding: 4.5rem 0 5rem;
padding: 5rem 0 5.5rem;
}

.sectionHead {
max-width: 640px;
max-width: 660px;
margin: 0 auto 3rem;
text-align: center;
}
Expand All @@ -18,8 +14,9 @@
}

.sectionSubtitle {
font-size: 1.1rem;
color: var(--ifm-color-emphasis-700);
font-size: 1.12rem;
line-height: 1.6;
color: var(--ck-muted);
margin: 0;
}

Expand All @@ -28,42 +25,67 @@
}

.card {
position: relative;
height: 100%;
padding: 1.75rem;
border-radius: 14px;
padding: 1.85rem;
border-radius: 1rem;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-200);
border: 1px solid var(--ck-line);
overflow: hidden;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}

.card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: var(--ck-gradient);
opacity: 0;
transition: opacity 0.18s ease;
}

.card:hover {
transform: translateY(-4px);
border-color: var(--ifm-color-primary);
box-shadow: 0 16px 40px rgba(138, 43, 226, 0.14);
transform: translateY(-5px);
border-color: color-mix(in srgb, var(--ck-indigo) 40%, transparent);
box-shadow: 0 20px 44px var(--ck-shadow);
}

.card:hover::before {
opacity: 1;
}

.cardIcon {
font-size: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 0.75rem;
font-size: 1.6rem;
line-height: 1;
margin-bottom: 1rem;
margin-bottom: 1.1rem;
background: color-mix(in srgb, var(--ck-indigo) 12%, transparent);
border: 1px solid var(--ck-line);
}

.cardTitle {
font-size: 1.2rem;
font-size: 1.22rem;
letter-spacing: -0.015em;
margin-bottom: 0.5rem;
}

.cardText {
color: var(--ifm-color-emphasis-700);
color: var(--ck-muted);
margin: 0;
line-height: 1.6;
line-height: 1.62;
}

.ctaRow {
display: flex;
justify-content: center;
margin-top: 1.5rem;
margin-top: 2rem;
}
Loading
Loading