Creating app elements
Once an app adds elements to a user's design, such as images or videos, the app can't edit those elements — it's as if they become invisible to the app.
Sometimes, this can be limiting.
For example, imagine an app that creates gradients. If the app creates the gradients as images, the user can't change the gradient once it exists. They can only create new gradients. To update a gradient:
- The user has to delete the previous gradient from their design.
- A new image has to be uploaded to the user's media library.
This is a sub-par user experience that app elements are designed to solve.
What are app elements?
An app element is a type of element that apps can modify after the element exists in the user's design. They have limitations and a more complex lifecycle, so it doesn't always make sense to use them, but if you're otherwise unable to create the app you want, app elements may be the answer.
Behind the scenes, app elements are groups of elements that:
- Can't be un-grouped — that is, they're locked groups
- Can have metadata attached to them
Like groups, app elements can be made up of multiple child elements, but unlike groups, app elements are allowed to contain a single element.
By attaching metadata to an element, the element's settings — for example, the colors of a gradient — can be persisted on the element itself. The app can update the metadata, causing the element to re-render. The end result is that the elements can be edited by the app that created them.
How to create app elements
Step 1: Enable the required permissions
In the Developer Portal, enable the following permissions:
canva:design:content:read
canva:design:content:write
In the future, the Apps SDK will throw an error if the required permissions are not enabled.
To learn more, see Configuring permissions.
Step 2: Define the app element's data structure
Create a type that represents data required to render the element. For example, for an app element to render a gradient, an appropriate type would need to hold at least two colors:
type AppElementData = {color1: string;color2: string;};
The name of the type is not important.
Step 3: Initialize the app element
Import the initAppElement
method from the @canva/design
package:
import { initAppElement } from "@canva/design";
Then call the method outside of a React component:
const appElementClient = initAppElement<AppElementData>({render: (data) => {const dataUrl = createGradient(data.color1, data.color2);return [{type: "image",dataUrl,width: 640,height: 360,top: 0,left: 0,},];},});
There's a few things going on here, so let's break it down:
- The
initAppElement
method should be called outside of a React component because the rendering of the element is not tied to the rendering of the component. - The type for the app element data is passed to the
initAppElement
method as a type argument. This ensures that we have accurate type information while working with the method. - The
initAppElement
method accepts an object as its only parameter. This object requires arender
function that determines the elements to render in the app element. It receives the app element data as its only argument and must return one or more elements. - The returned elements must have positional properties, including coordinates and dimensions. To learn more about these options, see Positioning elements.
- The
render
method should only rely on data that's passed in via thedata
parameter. Given the same data, it should return the same result.
In this particular example, the data is passed into the following createGradient
function that returns a data URL for a gradient:
function createGradient(color1: string, color2: string): string {const canvas = document.createElement("canvas");canvas.width = 640;canvas.height = 360;const ctx = canvas.getContext("2d");if (!ctx) {throw new Error("Can't get CanvasRenderingContext2D");}const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);gradient.addColorStop(0, color1);gradient.addColorStop(1, color2);ctx.fillStyle = gradient;ctx.fillRect(0, 0, canvas.width, canvas.height);return canvas.toDataURL();}
Step 4: Set the app element's data
To render (or re-render) an app element, call the addOrUpdateElement
method:
appElementClient.addOrUpdateElement({color1: "",color2: "",});
This method accepts an object that conforms to the structure of the app element's data.
If an app element isn't selected, one will be created and added to the user's design. If an app element is selected, its data will be updated and the element will be re-rendered.
The following code demonstrates how an app might allow the user to customize the values and then render the app element when the user clicks a button:
export function App() {const [state, setState] = React.useState({color1: "",color2: "",});function handleClick() {appElementClient.addOrUpdateElement({color1: state.color1,color2: state.color2,});}function handleChange(event: React.ChangeEvent<HTMLInputElement>) {setState((prevState) => {return {...prevState,[event.target.name]: event.target.value,};});}return (<div><div><inputtype="text"name="color1"value={state.color1}placeholder="Color #1"onChange={handleChange}/></div><div><inputtype="text"name="color2"value={state.color2}placeholder="Color #2"onChange={handleChange}/></div><button type="submit" onClick={handleClick}>Add or update element</button></div>);}
The maximum amount of data that can be attached to an app element is 5kb.
Step 5: Check if an app element is selected
In the previous code sample, we have this Add or update element button:
<button type="submit" onClick={handleClick}>Add or update element</button>
This isn't the ideal user experience. It'd be nicer if we showed an Add element button when an element isn't selected and an Update element button when an element is selected.
To do this, create an isSelected
property in the useState
hook:
const [state, setState] = React.useState({color1: "",color2: "",isSelected: false,});
Then, in a useEffect
hook, register a callback with the registerOnElementChange
method:
React.useEffect(() => {appElementClient.registerOnElementChange((element) => {console.log(element);});}, []);
This callback runs when:
- An app element's data changes
- A user selects an app element
- A user deselects an app element
The callback receives an element
parameter. When an element is selected, element
contains a value. Otherwise, it's undefined
. You can use this behavior to update the isSelected
state:
React.useEffect(() => {appElementClient.registerOnElementChange((element) => {if (element) {setState({isSelected: true,});} else {setState({isSelected: false,});}});}, []);
You can then update the UI based on the isSelected
state:
<button type="submit" onClick={handleClick}>{state.isSelected ? "Update element" : "Add element"}</button>
Step 6: Synchronize the UI
There's a problem with these text fields:
<div><div><inputtype="text"name="color1"value={state.color1}placeholder="Color #1"onChange={handleChange}/></div><div><inputtype="text"name="color2"value={state.color2}placeholder="Color #2"onChange={handleChange}/></div><button type="submit" onClick={handleClick}>Add or update element</button></div>
These fields always show the values of the previously selected app element. This is because the state of the fields only update in response to input change events — not in response to the user's selection. As a result, the UI falls out of sync.
Here's what should happen:
- If you select an app element, the fields should reflect the colors of the selected element.
- If you deselect an app element, the fields should reset to an empty string.
To fix this, use the registerOnElementChange
callback to update the state of the fields:
React.useEffect(() => {appElementClient.registerOnElementChange((element) => {if (element) {setState({color1: element.data.color1,color2: element.data.color2,isSelected: true,});} else {setState({color1: "",color2: "",isSelected: false,});}});}, []);
The element
parameter is an object that contains the app element's data. When an app element is selected, we can use this data to set the values of the text fields. If element
is undefined, we can reset the text fields to empty strings. As a result, the UI remains in sync with the user's selection.
Known limitations
- Users can't apply effects to app elements or the elements within them.
- Users can't select the individual elements within an app element.
- Users can't un-group the elements within an app element.
- App elements can only be edited via the apps that created them.
- App elements can't contain groups, tables, videos, or other app elements.
- Apps can only edit app elements while they're selected.
- The maximum amount of data that can be attached to an app element is 5kb.
API reference
Code sample
import React from "react";import { initAppElement } from "@canva/design";type AppElementData = {color1: string;color2: string;};const appElementClient = initAppElement<AppElementData>({render: (data) => {const dataUrl = createGradient(data.color1, data.color2);return [{type: "image",dataUrl,width: 640,height: 360,top: 0,left: 0,},];},});export function App() {const [state, setState] = React.useState({color1: "",color2: "",isSelected: false,});React.useEffect(() => {appElementClient.registerOnElementChange((element) => {if (element) {setState({color1: element.data.color1,color2: element.data.color2,isSelected: true,});} else {setState({color1: "",color2: "",isSelected: false,});}});}, []);function handleClick() {appElementClient.addOrUpdateElement({color1: state.color1,color2: state.color2,});}function handleChange(event: React.ChangeEvent<HTMLInputElement>) {setState((prevState) => {return {...prevState,[event.target.name]: event.target.value,};});}return (<div><div><inputtype="text"name="color1"value={state.color1}placeholder="Color #1"onChange={handleChange}/></div><div><inputtype="text"name="color2"value={state.color2}placeholder="Color #2"onChange={handleChange}/></div><button type="submit" onClick={handleClick}>{state.isSelected ? "Update" : "Add"}</button></div>);}function createGradient(color1: string, color2: string): string {const canvas = document.createElement("canvas");canvas.width = 640;canvas.height = 360;const ctx = canvas.getContext("2d");if (!ctx) {throw new Error("Can't get CanvasRenderingContext2D");}const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);gradient.addColorStop(0, color1);gradient.addColorStop(1, color2);ctx.fillStyle = gradient;ctx.fillRect(0, 0, canvas.width, canvas.height);return canvas.toDataURL();}