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?
App elements are 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,altText: {text: "A gradient background",decorative: false},},];},});
There's a few things going on here, so to 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 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 through 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. Add the following function after the React component to create a gradient image:
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: Add the app element's data
To add an app element, start by creating a variable for the app element state within the React component:
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, an app element is created and added to the user's design. If an app element is selected, its data updates and the element is 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: Update a selected app element
In the previous code sample, there's the 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:
- A user selects an app element.
- A user changes or updates an app element's data.
- 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 update
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}>{state.isSelected ? "Update element" : "Add 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.
How to create app elements (beta)
This API is in preview mode and may experience breaking changes. Apps that use this API will not pass the review process and can't be made available on the Apps Marketplace.
We have introduced a new approach for creating and updating elements in the beta version of the initAppElement
API.
The new addElement
method and update function solves the problem of inconsistent app element updates. When you select an app element, and update the metadata, if you select another app element before the update is complete, the metadata update could unintentionally apply to the newly selected app element. If no selection is made, it could unintentionally create a new app element.
This is a sub-par experience that the beta version of the initAppElement
API solves.
You can access preview methods and functions using the beta
dist-tag, and test the beta version of the initAppElement
API by modifying the gradient example in the previous section:
-
Install the beta version of the
@canva/design
package:npm install @canva/design@betaSHELL -
Import the
AppElementOptions
type:import { AppElementOptions, initAppElement } from "@canva/design";TSX -
Create a type outside of the React component that:
- Represents the data that can be stored on the app element
- Includes a function to handle app element updates
For example:
type AppElementChangeEvent = {data: AppElementData;update?: (opts: AppElementOptions<AppElementData>) => Promise<void>;};TSX -
Update the
state
variable to conform to theAppElementChangeEvent
type:export function App() {const [state, setState] = React.useState<AppElementChangeEvent>({data: {color1: "",color2: "",},});}TSX -
Modify the
useEffect
hook to use the data object:React.useEffect(() => {appElementClient.registerOnElementChange((element) => {if (element) {setState({data: {color1: element.data.color1,color2: element.data.color2,},update: element.update,});} else {setState({data: {color1: "",color2: "",},});}});}, []);TSX -
Modify the
handleClick
function to use theaddElement
method and the data object:function handleClick() {if (state.update) {state.update({data: state.data,});} else {appElementClient.addElement({data: state.data,});}}TSX -
Modify the
handleChange
function to use the data object:function handleChange(event: React.ChangeEvent<HTMLInputElement>) {setState((prevState) => {return {...prevState,data: {...prevState.data,[event.target.name]: event.target.value,},};});}TS -
Adjust the
input
values and Add element button state to use the data object and update function:return (<div><div><inputtype="text"name="color1"value={state.data.color1}placeholder="Color #1"onChange={handleChange}/></div><div><inputtype="text"name="color2"value={state.data.color2}placeholder="Color #2"onChange={handleChange}/></div><button type="submit" onClick={handleClick}>{state.update ? "Update" : "Add"}</button></div>);TSX
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
initAppElement
initAppElement
(beta)
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,altText: {text: "A gradient background",decorative: false},},];},});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();}
import React from "react";import { AppElementOptions, initAppElement } from "@canva/design";type AppElementData = {color1: string;color2: string;};type AppElementChangeEvent = {data: AppElementData;update?: (opts: AppElementOptions<AppElementData>) => Promise<void>;}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<AppElementChangeEvent>({data: {color1: "",color2: "",},});React.useEffect(() => {appElementClient.registerOnElementChange((element) => {if (element) {setState({data: {color1: element.data.color1,color2: element.data.color2,},update: element.update,});} else {setState({data: {color1: "",color2: "",},});}});}, []);function handleClick() {if (state.update) {state.update({data: state.data,});} else {appElementClient.addElement({data: state.data,})}}function handleChange(event: React.ChangeEvent<HTMLInputElement>) {setState((prevState) => {return {...prevState,data: {...prevState.data,[event.target.name]: event.target.value,},};});}return (<div><div><inputtype="text"name="color1"value={state.data.color1}placeholder="Color #1"onChange={handleChange}/></div><div><inputtype="text"name="color2"value={state.data.color2}placeholder="Color #2"onChange={handleChange}/></div><button type="submit" onClick={handleClick}>{state.update ? "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();}