DrawnUI for React — Skia canvas UI on CanvasKit

DrawnUI for React

A UI rendering engine on top of CanvasKit: layouts, controls, gestures, effects and animations

helloreact.drawnui.net · github.com/DrawnUi/DrawnUi.React · MIT · Publish 90

Recycled cells

100 000 recycled cells · last tapped: -

Uneven cells

200 uneven cells · MeasureVisible · LoadMore at both ends

Post 1

Renders idle ui product idle pixel drawn ui product recycled time canvas idle itself itself catalog renders renders feed in catalog pixel.

#1 · 138 chars

Post 2

Catalog in the ui every catalog cells cells in product idle cells pixel canvas the drawn in pixel every itself rows renders product pixel.

#2 · 138 chars

Post 3

Timeline and social itself in rows in idle cells refines in estimates idle measure news ui itself skia visible uneven and drawn product the uneven.

#3 · 147 chars

Post 4

News rest recycled recycled renders and product product drawn canvas refines and pixel the renders renders rows cells idle canvas recycled product timeline rows canvas uneven.

#4 · 175 chars

Post 5

Uneven the uneven visible refines cells skia canvas uneven timeline and idle in and measure every recycled visible product news renders social timeline renders rows product in.

#5 · 176 chars

Post 6

In visible every and ui pixel and and visible and rest rows every idle time pixel social the itself measure social news timeline measure recycled pixel itself in measure.

#6 · 170 chars

Post 7

Rest measure and idle and product social feed every itself the social in rows catalog pixel estimates and the timeline in uneven timeline time news measure rows idle visible itself.

#7 · 181 chars

Post 8

Estimates cells product rows drawn uneven pixel pixel news social estimates product renders feed canvas itself catalog in uneven estimates visible idle social catalog cells refines cells uneven estimates visible catalog.

#8 · 220 chars

Images

SkiaImage · Aspect

Same 512×512 photo in a 220×120 box. Overflow is clipped to the box.

AspectCover

AspectFit

AspectFill

Aspect="AspectCover"

Aspect="AspectFit"

Aspect="AspectFill"

AspectFitFill

Fill

Fit

Aspect="AspectFitFill"

Aspect="Fill"

Aspect="Fit"

FitFill

Cover

None

Aspect="FitFill"

Aspect="Cover"

Aspect="None"

Effects · AddEffect, Blur, Zoom, offsets

BlackAndWhite

Sepia

Pastel

InvertColors

Tint #0D6EFD Multiply

Darken={80}

Lighten={80}

Contrast={1.5}

Saturation={2}

Blur={3}

ZoomX/Y={1.8}

HorizontalOffset={-40}

HSL Gamma=0.6 (hue) Sat=1 Bright=0.5

Custom filters · AddEffect="Custom" + PaintColorFilter / PaintImageFilter

PaintColorFilter = MakeMatrix (R↔B)

PaintImageFilter = MakeDilate(4)

SkiaImageTiles · TileWidth/TileHeight, TileOffsetX animates

SkiaImageManager preload queue · idle

SVG

SkiaSvg

Source="images/drawnui.svg" WidthRequest={200} LockRatio={1}

TintColor

SvgString (inline markup) at three sizes

Rasterized by the browser at the displayed pixel size, re-rasterized only when that size changes.

Shapes

SkiaShape

Stroke is drawn inside the bounds; children are clipped to the shape.

ContextMenu

Right-click / long-press the shape: its ContextMenu handler takes the request and the browser menu stays away. Elsewhere on the canvas the browser menu shows as usual.

right-click me

ContextMenu -> toast

FillGradient / StrokeGradient

Linear · Angle={45}

Circular at (0.5, 0.5)

Oval · Light={1.4}

Sweep · TileMode Repeat

Gradient text, line by line

bg + text

StrokeGradient · StrokeWidth 8

SkiaLabel FillGradient → glyphs (GradientByLines)

Label: BackgroundColor + FillGradient = both

Bevel / Emboss

BevelType=Bevel · Depth 4

BevelType=Emboss · Depth 4

Circle · Bevel, colored edges

Polygon (star) · Emboss

Path (heart) · Bevel

Sharp rectangle · Bevel, Opacity 1

Shadows

Shadows=[{Y:4, Blur:6, Opacity:.5}]

Colored, offset X

Two shadows (glow + drop)

ShadowOnly + hollow ClipBackgroundColor

Types

Rectangle CornerRadius={16}

CornerRadius(24, 0, 0, 24)

Circle + stroke

Ellipse

Text

SkiaLabel

Word wrap · HorizontalOptions=Fill

DrawnUI draws every pixel itself: text is shaped and rasterized by Skia, so a label wraps by words, respects MaxLines with an ellipsis, aligns horizontally and vertically, and never leaves the canvas for a native view. This paragraph is long on purpose so it wraps across several lines at whatever width the layout gives it.

Spans — <TextSpan> children: color, size, bold, italic, underline, strikeout, background, Tapped

One label, many styles: bold, italic, colored, bigger, underlined, struck out, highlighted and a tappable link → that wraps with the rest of the paragraph like any other word.

Last span tap: nothing yet

Spans with colored emoji — the DrawnUI .NET demo; emoji sequences are shaped into one glyph

ANY span can be made tappable ! Use colored emojis 🌐🚒🙎🏽👻🤖 Format text as BiG and small! Add another tappable link! Still in development but.. You can already use the nuget!

SkiaRichLabel with an emoji run — the DrawnUI .NET demo

Just put a simple markdown string and this control will auto-split it into spans. It will recognize links a link rendered with SkiaMarkdownLabel Here it auto-created a span for 🚀🐴🤖👻 and switched back. More info... Subclass this control or consume LinkTappedCommand property or LinkTapped event handler to react to taps.

Right to left — Arabic, shaped and in bidirectional order · starts at the right (HorizontalTextAlignment Start)

مرحبًا بكم في عصر العناصر البصرية المصممة حيث الدقة تلتقي بالإبداع في كل تفصيل نقدم لكم تجربة مستخدم فريدة ومتطورة استمتع بالسلاسة والأداء العالي في التصميمات نحن نبتكر لنجعل تجربتكم أكثر تميزًا وسهولة

SkiaRichLabel — markdown in Text, rendered as spans

Heading 1 Heading 2 Heading 3 A paragraph with bold, italic, strikethrough, inline code and a tappable link. Soft line breaks stay inside the paragraph. • Bullet item with bold • Second bullet 1. Numbered item 2. Another one, emphasised const label = new SkiaRichLabel(); label.Text = "# Hello";

Last link tapped: none

MaxLines={2} · TailTruncation (default)

DrawnUI draws every pixel itself: text is shaped and rasterized by Skia, so a label wraps by words, respects MaxLines with an ellipsis, aligns horizontally and vertically, and never leaves the canvas for a native view. This paragraph is long on purpose so it wraps across several lines at whatever width the layout gives it.

LineSpacing={1.6}

DrawnUI draws every pixel itself: text is shaped and rasterized by Skia, so a label wraps by words, respects MaxLines with an ellipsis, aligns horizontally and vertically, and never leaves the canvas for a native view. This paragraph is long on purpose so it wraps across several lines at whatever width the layout gives it.

HorizontalTextAlignment Start / Center / End

Start aligned text wraps inside its own column

Center aligned text wraps inside its own column

End aligned text wraps inside its own column

VerticalTextAlignment in a 90pt box

Start

Center

Layouts

SkiaLayout types

One class, six Type values; the aliases preset Type (+ HorizontalOptions=Fill for SkiaStack / SkiaLayer / SkiaWrap / SkiaGrid / SkiaMasonry). Children position themselves with HorizontalOptions / VerticalOptions / Margin, the container adds Spacing and Padding.

Absolute · SkiaLayer

Children overlap in one cell; alignment + Margin place them (WPF-style, no X/Y)

Start/Start

Center/Start

End/Start

Start/Center

Center/Center

End/Center

Start/End

Margin(0,0,0,12)

End/End

Icon + text with an Absolute layer instead of a grid (cheaper): label gets the icon's width as Margin

Margin={new Thickness(48, 0, 0, 0)} VerticalOptions=Center — no second column to measure.

IsClippedToBounds — a child larger than its parent

overflows (default)

IsClippedToBounds

ClipEffects={false}

Column · SkiaStack

ZIndex draws later (on top); HorizontalFillRatio/VerticalFillRatio = fraction of the box; Left/Top nudge the drawn output

ZIndex=2 · FillRatio 0.5 × 0.75

ZIndex=1, declared second

Left=-20 Top=-10 · ZIndex=3

Vertical stack, Spacing between children, each child aligns horizontally on its own

Start (default)

HorizontalOptions=Center

HorizontalOptions=End

HorizontalOptions=Fill

WidthRequest={200}

Row · SkiaRow

Horizontal stack; the row is as tall as its tallest child, children align vertically

Start

Center

Fill

Margin(16,0,0,0)

End

A Row gives children an infinite width: Fill on the main axis auto-sizes (MAUI stack semantics). Use SkiaGrid with a * column when something must take the remaining width.

Grid · SkiaGrid

ColumnDefinitions="*, 2*, Auto" RowDefinitions="Auto, 60" · ColumnSpacing/RowSpacing 8

*

2*

Auto (this label)

Row 1 = 60pt

Column 1

Auto

ColumnSpan / RowSpan

ColumnSpan=2

RowSpan=2

0,1

1,1

Common Controls

Common Controls

SkiaSwitch, SkiaCheckbox, SkiaRadioButton, SkiaButton, SkiaProgress, SkiaSlider — the same tree per card, only ControlStyle changes (the Fiddle 'Looks' snippet).

Last: interact with any control

Live — Unset

Default

Windows — Fluent

Cupertino — iOS

Material — Android

Material3 — Android

Carousel & Drawer

Carousel & Drawer

SkiaCarousel playground (Sandbox MainPageCarousels)

1

2

3

4

Selected Index: 0 · InTransition: false · Looping disabled - bounded scroll · ItemAppearing 0

Drag me

Swipe Speed

Current: 1.0x

Drawer content

Everything inside is a normal drawn tree: buttons keep working, the drawer only takes vertical drags. Release with a flick to snap open or closed.

SidesOffset={40} Spacing={12} — neighbours peek in · SelectedIndex=1

Slide 1

Slide 2

Slide 3

Slide 4

IsLooped + ItemsSource/ItemTemplate (12 recycled cells) · SelectedIndex=0

Item 12

Item 1

Item 2

recycled cell · index 11

recycled cell · index 0

recycled cell · index 1

Wraps last → first both ways (virtual anchors); LinearSpeedMs=350 = one slide per 350 ms without Bounces; cells are recycled through ItemTemplate.

DynamicSize — auto height follows the selected slide · SelectedIndex=0

80 pt

160 pt

recycled cell · index 0

recycled cell · index 1

No HeightRequest: the carousel measures the selected cell (80 / 160 / 110 pt) and re-measures on every index change.

SkiaDrawer — drag the header below, or:

IsOpen: false

Direction=FromBottom HeaderSize=56, sits in a SkiaLayer with VerticalOptions=End; snaps by velocity, Bounces enabled.

Lottie & GIF

Lottie & GIF

SkiaLottie — Source="lottie/shield.json" Repeat={-1} · Started

SpeedRatio

Skottie renders the vector animation every frame into an Operations cache (a picture); the animator is the C# RangeAnimator over InPoint..OutPoint.

ColorTint / Colors — colors replaced in the JSON before parsing (C# ApplyTint)

IsOn toggle — AutoPlay={false}, DefaultFrame=0 / DefaultFrameWhenOn=-1 · IsOn=false

Stopped animations show DefaultFrame, or DefaultFrameWhenOn (-1 = last frame) when IsOn: the C# recipe for animated checkboxes.

SkiaGif — Source="images/banana.gif" Aspect=AspectFitFill · 8 frames, 800 ms, playing

Every frame is decoded once (CanvasKit AnimatedImage); the animator runs over 0..DurationMs and picks the frame by time, like C# GifAnimation.

Shell

SkiaShell

Route=shell · NavigationStack=[shell] · Popups=0 · Modals=0 · Toasts=0

Pages live in the URL hash (#/shell/shapes); the browser Back button closes the top popup, then the top modal, then pops the page — the C# GoBack order.

Pages — GoToAsync slides the page in from the right (PagesAnimationSpeed 200 ms), GoBackAsync slides it out

Tabs — a nested SkiaShell with Tabs: per-tab navigation stacks, AnimateTabs (C# SkiaViewSwitcher slide + fade), PopTabToRootAsync

Home

Tab 0 · stack []

RouteChanged ''

SkiaBackdrop — the Sandbox MainPageBackdrop frosted glass, same tree

Wonnabe Frosted Glass

Popups — OpenPopupAsync(content, options)

Modals — PushModalAsync(content, { useGestures, animated })

Toasts — ShowToast(text | content, msShowTime)

Editor

SkiaEditor

Single line — Text="" · IsFocused=false · · submitted: ""

A focused editor is mirrored by a hidden textarea (TextInputProxy): IME composition, the mobile soft keyboard, autocorrect, native paste / cut / undo land in the drawn editor through the same stub methods the physical keyboard uses.

ControlStyle — Cupertino, Material, Material3, Windows (C# ApplyControlStyleVisuals palettes)

IsPassword — 0 chars hidden behind bullets, KeyboardType Numeric / Email input modes

Multiline — MaxLines={4}: Enter inserts a line, the box scrolls to the caret

​

Multiline + AutoHeight — MaxLines={-1}: the editor grows with the text

Chat input — MaxLines={3} ReturnType="Send": Enter submits and keeps focus, Shift+Enter breaks the line · 0 sent

​

Keyboard Input

Keyboard Input

KeyboardManager attaches window-level keydown / keyup listeners like the DrawnUi.Blazor and Wasm heads (drawnui-keyboard.js): shortcuts, game input, drawn editors. Click the page, then press letters, arrows, modifiers or function keys.

Keyboard input ready

Press letters, arrows, modifiers, or function keys

Last key: waiting

Modifiers: shift false, ctrl false, alt false

KeyChar (printable, no Ctrl/Alt): ""

Recent events

Waiting for input

Waiting for input

Waiting for input

Waiting for input

Waiting for input

SkiaScroll

SkiaScroll

Header + Footer in the flow · Tag="Header" / Tag="Footer" children scroll with the content

Header (70 pt) · scrolls away

Row 1

Row 2

Row 3

Row 4

HeaderSticky · the header stays at the top, drawn over the content

Row 5

Row 6

Sticky header

Row 7

Under sticky 1

Row 8

Under sticky 2

Row 9

Under sticky 3

Row 10

Under sticky 4

Row 11

Under sticky 5

HeaderBehind + HeaderParallaxRatio=0.5 · the content covers the header, which moves at half speed

Row 12

Under sticky 6

Row 13

Under sticky 7

Parallax cover

Row 14

Under sticky 8

Under sticky 9

Footer (50 pt) · after the content

Content over the cover 1

Under sticky 10

Content over the cover 2

Under sticky 11

Content over the cover 3

Under sticky 12

SkiaScrollBar Tag="ScrollBar" IsDraggable · drag the thumb or press the track, auto-hides 1 s after scrolling · horizontal: ScrollBarsVisibility="Horizontal"

Content over the cover 4

Under sticky 13

Content over the cover 5

Under sticky 14

Scrollbar row 1

Content over the cover 6

Scrollbar row 2

Content over the cover 7

Scrollbar row 3

Content over the cover 8

Scrollbar row 4

Content over the cover 9

Scrollbar row 5

Content over the cover 10

H 1

H 2

H 3

H 4

H 5

H 6

H 7

H 8

H 9

H 10

H 11

H 12

H 13

H 14

Scrollbar row 6

Content over the cover 11

Scrollbar row 7

RefreshEnabled + RefreshIndicator (Tag="RefreshIndicator") · RefreshDistanceLimit 60, RefreshShowDistance 50 · pull down past 60 pt

Content over the cover 12

Scrollbar row 8

Pull down 1

Scrollbar row 9

Pull down 2

Scrollbar row 10

Pull down 3

Scrollbar row 11

Pull down 4

Scrollbar row 12

Pull down 5

Scrollbar row 13

Pull down 6

Scrollbar row 14

SnapToChildren="Center" + TrackIndexPosition="Center" (horizontal) · CurrentIndex -1

Pull down 7

Scrollbar row 15

Shaders

Shaders

SkiaShaderCarousel — TransitionShader="shaders/transitions/cube.sksl" · IsLooped · LinearSpeedMs=750 · 0 → 1

Slide 1

Slide 2

Slide 4

Slides never move: a ShaderTransitionEffect blends the Image caches of the outgoing and incoming slides (iImage1 / iImage2, progress, ratio) through a gl-transitions style transition(uv) wrapped by the adapter template. Swipe, or drag slowly to scrub the transition; a swipe during a transition wraps it up first (InterruptedTransitionMs).

SkiaShaderEffect on a SkiaImage — ShaderSource="shaders/ripples.sksl" (Sandbox MultiRippleWithTouchEffect) · tap to ripple

The effect is an ISkiaGestureProcessor: every Down starts a ripple at the touch point, animated 0→1 over 4.5 s through Parent.AnimateRangeAsync and passed as the origins[10] / progresses[10] array uniforms; iImage1 is the image's own cache, iImage2 (SecondarySource) the reflection texture.

Inline ShaderCode + SetUniform("strength", 0.01) + iTime · animating

Generative shader — UseBackground="Never" on a SkiaLayer (no input texture)

ShaderSource="shaders/blit.sksl" (pass-through) toggled through VisualEffects · off

Sprites

Sprites

SkiaSprite — Source="anims/BlueWarrior/Warrior_Idle.png" Columns={8} Rows={1} · 8 frames · 192×192 px · 533 ms

Frames are cut from the sheet by Columns × Rows, transparent borders trimmed per frame (C# SpriteFrameImage), nearest sampling; the animator runs 0..DurationMs and picks the frame by time.

SkiaSpriteSet warrior on a tile board — arrows / WASD move, Space attacks · tile 1,1 · IdleRight

WarriorSprite extends SkiaSpriteSet: Define(0 idle, 1 run, 2 attack) with the FastRepro sheets; WState maps to State and mirrors CurrentSprite.ScaleX; the move is a TranslateToAsync to the target tile while the walk state plays.

Transforms

Transforms

Applied at render around the arranged box, so layout is untouched and caches stay valid. Same names as MAUI: TranslationX/Y, Rotation, RotationX/Y, ScaleX/Y, SkewX/Y, AnchorX/Y, Opacity, plus TranslationZ for 3D depth.

One property each

DrawnUI

DrawnUI

DrawnUI

DrawnUI

DrawnUI

none

Rotation={15}

RotationX={45}

RotationY={45}

Scale={1.3}

DrawnUI

DrawnUI

DrawnUI

DrawnUI

DrawnUI

ScaleX={-1}

SkewX={20}

TranslationY={10}

Opacity={0.35}

Rotation={15} AnchorX/Y={0}

3D — RotationX, RotationY, TranslationZ, with perspective

DrawnUI

Tap to flip

RotationX 0° · RotationY 0° · TranslationZ 0

RotationX

RotationY

TranslationZ

Perspective is built in: RotationX turns around the horizontal axis, RotationY around the vertical one, TranslationZ moves the control toward (+) or away from (−) the viewer. The card keeps its Image cache while it turns; it shows the back face once it faces away.

Gestures map through 3D — swipe and tap the tilted strip

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

Tapped: none

RotationX 16, RotationY 20, Rotation −6. A sideways swipe scrolls the strip and a tap picks the tile under the finger: input goes through the inverse of the 3D matrix. An up or down swipe still scrolls the page.

Gestures map through transforms — tap the rotated, scaled button

The hit rect is the drawn one (inverse RenderTransformMatrix), not the layout box; the ripple lands under the finger.

Animations — FadeToAsync, ScaleToAsync, TranslateToAsync, RotateToAsync (Promises)

Drag to reorder

50 languages in order of preference · drag by the grip, hold at an edge to keep going

drag a language by its grip, or use the buttons

English (United States)

en-US

Español (España)

es-ES

Français (France)

fr-FR

Deutsch (Deutschland)

de-DE

Italiano (Italia)

it-IT

Português (Brasil)

pt-BR

Nederlands (Nederland)

nl-NL

Svenska (Sverige)

sv-SE

Norsk bokmål (Norge)

nb-NO

Dansk (Danmark)

da-DK

Suomi (Suomi)

fi-FI

Íslenska (Ísland)

is-IS

Polski (Polska)

pl-PL

Čeština (Česko)

cs-CZ

Slovenčina (Slovensko)

sk-SK

Pong

← → or drag to move, tap / Space to serve · first to 7

0 : 0

TAP TO SERVE

Accessibility

Accessibility

Press Tab to move between the drawn controls, Enter or Space to activate. Screen readers see the same overlay: roles, labels, hints, pressed state, live regions.

Accessibility snapshot (Canvas.AccessibilityManager)

Nodes in the overlay: 24 · focused: none · last activated: -

AccessibilityTextSelectable — real selectable text over the drawn label (opt-in)

This paragraph is drawn on the canvas, but the accessibility overlay also carries it as real, invisible text in the same font and line positions. Select it with the mouse or touch, copy it with Ctrl+C, let a screen reader read it word by word — like any HTML paragraph. Off by default: pointer input over selectable text goes to the selection, not to the drawn control, so it is never turned on for buttons, carousels or anything gesture-driven.

This one is a normal label: exposed to screen readers, not selectable.

Buttons — label from Text, hint, custom label, disabled

Toggles — AccessibilityIsPressed, in a toolbar

Any control can be a node — SkiaShape as a button, image with a description

The yellow circle is decorative: AccessibilityRole=Aria.RolePresentation keeps it out of the tree.

Keyboard groups — one Tab stop, the arrow keys inside

A container with a composite role (Aria.RoleList, RoleToolbar, RoleGrid...) is one Tab stop: the arrow keys move between its items, Home and End go to the first and the last, Enter or Space activates. Tab comes back to the item it left.

Fruits — a list: Up and Down

Numbers — a grid: all four arrows

Labels — read by default, opted out per control

This label is announced: SkiaLabel.DefaultAccessibilityRole = Aria.RoleText was set once at startup.

Heading level text

How it works

• <canvas> is aria-hidden; a DOM overlay mirrors accessible controls with role / aria-label / title / aria-pressed / aria-live / tabindex. • Snapshot rebuilt at most once per second from the arranged rects, so it follows scrolling. • Overlay has pointer-events:none — hover and gestures reach the canvas; keyboard and screen-reader activation are routed as a Tapped. • Same property names as DrawnUi (.NET): AccessibilityRole, AccessibilityLabel, AccessibilityHint, AccessibilityCanInteract, AccessibilityIsPressed, AccessibilityLive.