Skip to Content
For DevelopersThe .pen Format

The .pen Format

Pencil documents are stored in .pen files. This documentation is for developers who would like to read or write .pen files.

The following sections provide a birds-eye view of the .pen format. For the authoritative, exhaustive reference of all the supported features, please consult the TypeScript schema at the end of this page.

This is a live documentation, and we reserve the right to introduce breaking changes in the .pen format.

Overview

  • .pen files contain a JSON structure, that describes an object tree, not unlike HTML or SVG.
  • Each object in the document is a graphical entity on Pencil’s infinite two-dimensional canvas.
  • The objects must have an id property that uniquely identifies them within the document, and a type field from one of the possible object types (like rectangle, frame, text, script, etc. – consult the TypeScript schema for the exhaustive list of supported types).

Layout

  • The top-level objects in a document are placed on an infinite two-dimensional canvas. They must have x and y properties that describe the location of their top-left corner.
  • Objects nested under other objects are positioned relative to their parents’ top-left corner.
  • A parent object can take over the sizing and positioning of its children using a flexbox-style layout system via properties like layout, justifyContent and alignItems.
  • Child objects can choose to fill their parent, or use a fixed width and/or height.
  • Parent objects can choose to fit the size of their children, or use a fixed width and/or height.

Graphics

  • The graphical appearance of objects is controlled by the fill, stroke and effect properties.
  • A fill can be a solid color, a gradient (linear, radial or angular), an image or a mesh_gradient.
  • An object can have multiple fills, which are painted on top of each other the same order they appear in the document.
  • An object can have a single stroke, but the stroke can have multiple fills.
  • An object can have multiple effects, which are applied in the same order they appear in the document.

Components and Instances

A key difference between Pencil documents and HTML or SVG is that Pencil documents allow reusing existing chunks of the object tree at different places. This enables the building of reusable components, that can be used as concise building blocks for more complicated structures.

Components

When an object is marked with the property reusable: true, it becomes a reusable component:

{ "id": "foo", "type": "rectangle", "reusable": true, // <- this object is now a reusable component "x": 0, "y": 0, "width": 100, "height": 100, "fill": "#FF0000" }

Instances

The object type ref is used to create an instance of such components:

{ "id": "bar", "type": "ref", "ref": "foo", // <- this object is an instance of the component "foo" "x": 120, "y": 0 }

Here foo is a 100x100 red (#FF0000) square, and a reusable component. bar is an instance of foo, so it is also a 100x100 red square.

Overrides

Instances can override properties from their component definition:

{ "id": "baz", "type": "ref", "ref": "foo", "x": 240, "y": 0, "fill": "#0000FF" }

Even though baz is an instance of foo, it overrides the inherited fill property with a different one. So it’s going to be a 100x100 blue (#0000FF) square!

Nesting

An instance replicates everything under the component root:

{ "id": "round-button", "type": "frame", "reusable": true, "cornerRadius": 9999, "children": [ { "id": "label", "type": "text", "content": "Submit", "fill": "#000000" ... } ] } { "id": "red-round-button", "type": "ref", "ref": "round-button", "fill": "#FF0000" }

Here red-round-button will have an identical "Submit" label as round-button. But this label, too, can be customized using the descendants property:

{ "id": "red-round-button", "type": "ref", "ref": "round-button", "fill": "#FF0000", "descendants": { "label": { // <- "label" is the `id` of the object under "red-round-button" that we want to customize "text": "Cancel", "fill": "#FFFFFF" } } }

Now the red button’s label will be white, and say "Cancel".

Components can be built from instances of other components:

{ "id": "alert", "type": "frame", "reusable": true, "children": [ { "id": "message", "type": "text", "content": "This is an alert!", "fill": "#000000" ... }, { "id": "ok-button", "type": "ref", "ref": "round-button", "descendants": { "label": { "text": "OK" } } }, { "id": "cancel-button", "type": "ref", "ref": "round-button", "descendants": { "label": { "text": "Cancel" } } } ] }

And children of nested instances can be customized by prefixing their IDs with the containing instance’s ID and a slash in the descendants map:

{ "id": "save-alert", "type": "ref", "ref": "alert", "descendants": { "message": { "content": "You have unsaved changes. Do you want to save them?" }, "ok-button/label": { // <- we're customizing the "label" under "ok-button" "content": "Save" }, "cancel-button/label": { // <- we're customizing the "label" under "cancel-button" "content": "Discard Changes", "fill": "#FF0000" } } }

In addition to customization, an object inside an instance can be completely replaced with new object:

{ "id": "icon-button", "type": "ref", "ref": "round-button", "descendants": { "label": { "id": "icon", "type": "icon_font", // <- the presence of the `type` property indicates that this is an object replacement "iconFontFamily": "lucide", "icon": "check" } } }

Alternatively to 1:1 replacement, an object can be kept as is, and we can replace only its children with new objects:

{ "id": "sidebar" "type": "frame", "reusable": true, "children": [ { "id": "header", "type": "frame", "fill": "#FF0000" }, { "id": "content", "type": "frame", "fill": "#00FF00" }, { "id": "footer", "type": "frame", "fill": "#0000FF" } ] } { "id": "menu-sidebar" "type": "ref", "ref": "sidebar", "descendants": { "content": { "children": [ // <- the children of "content" are replaced with some "round-button" instances { "id": "home-button", "type": "ref", "ref": "round-button", "descendants": { "label": { "text": "Home" } } }, { "id": "settings-button", "type": "ref", "ref": "round-button", "descendants": { "label": { "text": "Settings" } } }, { "id": "help-button", "type": "ref", "ref": "round-button", "descendants": { "label": { "text": "Help" } } } ] } } }

This children replacement mechanism is ideal for container-style components, like panels, cards, windows, sidebars, etc.

Slots

When a frame inside a component is intended to have its children replaced (e.g. the content holder frame inside a panel), it can be marked with the slot property:

{ "id": "sidebar" "type": "frame", "reusable": true, "children": [ { "id": "header", "type": "frame", "fill": "#FF0000" }, { "id": "content", "type": "frame", "fill": "#00FF00", "slot": [ // <- "content" is marked as a slot, which is intended to be populated with "round-button" or "icon-button" instances "round-button", "icon-button" ] }, { "id": "footer", "type": "frame", "fill": "#0000FF" } ] }

Pencil displays such slots with a special effect, and lets users insert instances of the suggested components (i.e. round-button or icon-button above) with a single click.

Variables and Themes

Pencil supports extracting commonly used colors and numeric values (padding, corner radius, opacity, etc.) into document-wide variables:

{ "variables": { "color.background": { "type": "color", "value": "#FFFFFF" }, "color.text": { "type": "color", "value": "#333333" }, "text.title": { "type": "number", "value": 72 } }, "children": [ { "id": "landing-page", "type": "frame", "fill": "$color.background", "children": [ { "id": "welcome-label", "type": "text", "fill": "$color.text", "fontSize": "$text.title", "content": "Welcome!" } ] } ] }

Pencil also implements a powerful theming system, whereby variables can dynamically change their values depending on the theme configuration of each object:

{ "variables": { "color.background": { "type": "color", "value": [ // <- when a variable has multiple values, the value that wins during evaluation is the _last_ one whose theme is satisfied { "value": "#FFFFFF", "theme": { "mode": "light" } }, { "value": "#000000", "theme": { "mode": "dark" } } ] }, "color.text": { "type": "color", "value": [ { "value": "#333333", "theme": { "mode": "light" } }, { "value": "#AAAAAA", "theme": { "mode": "dark" } } ] }, "text.title": { "type": "number", "value": [ { "value": 72, "theme": { "spacing": "regular" } }, { "value": 36, "theme": { "spacing": "condensed" } } ] } }, "themes": { // <- the default value of each theme axis is the first value, so the default theme is { "mode": "light", "spacing": "regular" } "mode": ["light", "dark"], "spacing": ["regular", "condensed"] }, "children": [ { "id": "landing-page-light", "type": "frame", "fill": "$color.background", // #FFFFFF "children": [ { "id": "welcome-label", "type": "text", "fill": "$color.text", // #333333 "fontSize": "$text.title", // 72 "content": "Welcome!" } ] }, { "id": "landing-page-dark", "type": "frame", "theme": { "mode": "dark" }, // <- everything under this frame is using "mode": "dark" "fill": "$color.background", // #000000 "children": [ { "id": "welcome-label", "type": "text", "fill": "$color.text", // #AAAAAA "fontSize": "$text.title", // 72 "content": "Welcome!" } ] }, { "id": "landing-page-dark-condensed", "type": "frame", "fill": "$color.background", // #000000 "theme": { "mode": "dark", "spacing": "condensed" }, // <- everything under this frame is using { "mode": "dark", "spacing": "condensed" } "children": [ { "id": "welcome-label", "type": "text", "fill": "$color.text", // #AAAAAA "fontSize": "$text.title", // 36 "content": "Welcome!" } ] }, ] }

TypeScript Schema

/** Theme axis -> axis value. E.g. { 'device': 'phone' } */ export interface Theme { [key: string]: string; } /** Dollar-prefixed variable name; binds the property to that variable. */ export type Variable = string; export type NumberOrVariable = number | Variable; /** Hex color: #RGB, #RRGGBB, or #RRGGBBAA. */ export type Color = string; export type ColorOrVariable = Color | Variable; export type BooleanOrVariable = boolean | Variable; export type StringOrVariable = string | Variable; export interface Layout { /** Flex layout direction. 'none'=absolutely positioned children. */ layout?: "none" | "vertical" | "horizontal"; /** Main-axis gap between children. Default 0. */ gap?: NumberOrVariable; layoutIncludeStroke?: boolean; /** Inside padding. */ padding?: | /** all sides */ NumberOrVariable | /** [vertical, horizontal] */ [NumberOrVariable, NumberOrVariable] | /** [top, right, bottom, left] */ [ NumberOrVariable, NumberOrVariable, NumberOrVariable, NumberOrVariable, ]; /** Main-axis alignment. Default 'start'. */ justifyContent?: | "start" | "center" | "end" | "space_between" | "space_around"; /** Cross-axis alignment. Default 'start'. */ alignItems?: "start" | "center" | "end"; } /** Dynamic layout size: - fit_content: combined size of children (fallback when none). - fill_container: parent size (fallback when parent has no layout). Optional fallback in parens, e.g. 'fit_content(100)'. */ export type SizingBehavior = string; /** Position relative to parent. X right, Y down. IGNORED when parent uses flex layout. */ export interface Position { x?: number; y?: number; } export interface Size { width?: NumberOrVariable | SizingBehavior; height?: NumberOrVariable | SizingBehavior; } export type BlendMode = | "normal" | "darken" | "multiply" | "linearBurn" | "colorBurn" | "light" | "screen" | "linearDodge" | "colorDodge" | "overlay" | "softLight" | "hardLight" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity"; export type Fill = | ColorOrVariable | { type: "color"; enabled?: BooleanOrVariable; blendMode?: BlendMode; /** Fill opacity can only be set via the hex alpha channel. */ color: ColorOrVariable; } | { type: "gradient"; enabled?: BooleanOrVariable; blendMode?: BlendMode; gradientType?: "linear" | "radial" | "angular"; opacity?: NumberOrVariable; /** Normalized to bbox. Default 0.5,0.5. */ center?: Position; /** Normalized to bbox. Default 1,1. Linear: height = gradient length, width ignored. Radial/Angular: ellipse diameters. */ size?: { width?: NumberOrVariable; height?: NumberOrVariable }; /** Degrees CCW (0° up, 90° left, 180° down). */ rotation?: NumberOrVariable; colors?: { color: ColorOrVariable; position: NumberOrVariable }[]; } /** Image fill. URL is relative to the .pen file, e.g. `./image.jpg`. */ | { type: "image"; enabled?: BooleanOrVariable; blendMode?: BlendMode; opacity?: NumberOrVariable; url?: string; mode?: "stretch" | "fill" | "fit"; } /** Bezier-interpolated color grid, row-major. Keep edge points at default positions. */ | { type: "mesh_gradient"; enabled?: BooleanOrVariable; blendMode?: BlendMode; opacity?: NumberOrVariable; columns?: number; rows?: number; /** Color per vertex. */ colors?: ColorOrVariable[]; /** columns * rows points in [0,1]. */ points?: ( | /** Auto-generated handles. */ [number, number] | /** Optional bezier handles (relative offsets); omitted = auto. */ { position: [number, number]; leftHandle?: [number, number]; rightHandle?: [number, number]; topHandle?: [number, number]; bottomHandle?: [number, number]; } )[]; }; export type Fills = Fill | Fill[]; export interface Stroke { align?: "inside" | "center" | "outside"; thickness?: | NumberOrVariable | { top?: NumberOrVariable; right?: NumberOrVariable; bottom?: NumberOrVariable; left?: NumberOrVariable; }; join?: "miter" | "bevel" | "round"; miterAngle?: NumberOrVariable; cap?: "none" | "round" | "square"; dashPattern?: number[]; fill?: Fills; } export type Effect = /** Blurs the layer content. */ | { enabled?: BooleanOrVariable; type: "blur"; radius?: NumberOrVariable } /** Blurs background behind the layer. */ | { enabled?: BooleanOrVariable; type: "background_blur"; radius?: NumberOrVariable; } /** Inner or outer drop shadow. */ | { type: "shadow"; enabled?: BooleanOrVariable; shadowType?: "inner" | "outer"; offset?: { x: NumberOrVariable; y: NumberOrVariable }; spread?: NumberOrVariable; blur?: NumberOrVariable; color?: ColorOrVariable; blendMode?: BlendMode; }; export type Effects = Effect | Effect[]; export interface CanHaveGraphics { stroke?: Stroke; fill?: Fills; effect?: Effects; } export interface CanHaveEffects { effect?: Effects; } export interface Entity extends Position { /** Unique string; MUST NOT contain '/'. Auto-generated if omitted. */ id: string; name?: string; context?: string; /** When true, can be duplicated via `ref` objects. Default false. */ reusable?: boolean; theme?: Theme; enabled?: BooleanOrVariable; opacity?: NumberOrVariable; flipX?: BooleanOrVariable; flipY?: BooleanOrVariable; layoutPosition?: "auto" | "absolute"; metadata?: { type: string; [key: string]: any }; /** Degrees CCW around top-left corner. */ rotation?: NumberOrVariable; } export interface Rectangleish extends Entity, Size, CanHaveGraphics { cornerRadius?: | NumberOrVariable | [NumberOrVariable, NumberOrVariable, NumberOrVariable, NumberOrVariable]; } /** Position is the top-left corner. */ export interface Rectangle extends Rectangleish { type: "rectangle"; } /** Defined by its bounding rectangle. */ export interface Ellipse extends Entity, Size, CanHaveGraphics { type: "ellipse"; /** Ring inner/outer radius ratio. 0=solid, 1=hollow. Default 0. */ innerRadius?: NumberOrVariable; /** Arc start angle, degrees CCW from right. Default 0. */ startAngle?: NumberOrVariable; /** Arc length from startAngle. Positive=CCW, negative=CW. Range -360..360. Default 360. */ sweepAngle?: NumberOrVariable; } /** Defined by its bounding rectangle. */ export interface Polygon extends Entity, Size, CanHaveGraphics { type: "polygon"; polygonCount?: NumberOrVariable; cornerRadius?: NumberOrVariable; } export interface Path extends Entity, Size, CanHaveGraphics { /** Default 'nonzero'. */ fillRule?: "nonzero" | "evenodd"; /** SVG path. */ geometry?: string; /** SVG coord-space [x,y,w,h] mapping onto the node box. Default: tight bbox of geometry. */ viewBox?: [number, number, number, number]; type: "path"; } export interface TextStyle { fontFamily?: StringOrVariable; fontSize?: NumberOrVariable; fontWeight?: StringOrVariable; letterSpacing?: NumberOrVariable; fontStyle?: StringOrVariable; underline?: BooleanOrVariable; /** Multiplier of fontSize. Defaults to font's built-in. */ lineHeight?: NumberOrVariable; textAlign?: "left" | "center" | "right" | "justify"; textAlignVertical?: "top" | "middle" | "bottom"; strikethrough?: BooleanOrVariable; href?: string; } export type TextContent = StringOrVariable; export interface Text extends Entity, Size, CanHaveGraphics, TextStyle { type: "text"; content?: TextContent; /** Required before width/height take effect. 'auto': grows to fit; no wrapping. 'fixed-width': width fixed, wraps; height grows. 'fixed-width-height': both fixed; may overflow. */ textGrowth?: "auto" | "fixed-width" | "fixed-width-height"; } export interface CanHaveChildren { children?: Child[]; } /** Container to create hierarchy and layout. default layout=horizontal, width=fit_content, height=fit_content, clip=false. */ export interface Frame extends Rectangleish, CanHaveChildren, Layout { type: "frame"; /** Clip overflow. Default false. */ clip?: BooleanOrVariable; placeholder?: boolean; /** Marks frame as a slot for component instances. Array entries are IDs of recommended reusable child components (e.g. menu items inside a menu bar). */ slot?: false | string[]; } export interface Group extends Entity, CanHaveChildren, CanHaveEffects { type: "group"; } export interface Note extends Entity, Size, TextStyle { type: "note"; content?: TextContent; } export interface Prompt extends Entity, Size, TextStyle { type: "prompt"; content?: TextContent; model?: StringOrVariable; } export interface Context extends Entity, Size, TextStyle { type: "context"; content?: TextContent; } /** Icon from a library. The icon is scaled to fit the width and height. There is no font-size */ export interface IconFont extends Entity, Size, CanHaveEffects { type: "icon_font"; iconFontName?: StringOrVariable; /** Valid: 'lucide', 'feather', 'Material Symbols Outlined', 'Material Symbols Rounded', 'Material Symbols Sharp', 'phosphor'. */ iconFontFamily?: StringOrVariable; /** Variable font weight, 100-700; only for variable-weight fonts. */ weight?: NumberOrVariable; fill?: Fills; } /** Generates nested children from JavaScript. */ export interface Script extends Entity, Size { type: "script"; /** Clip overflow. Default false. */ clip?: BooleanOrVariable; /** JS file URI, relative to the .pen file. */ scriptUri?: string; /** Input values by name. */ inputs?: { [key: string]: string | number | boolean | Variable }; } /** Reuses another object. */ export interface Ref extends Entity { type: "ref"; /** ID of the referenced object. */ ref: string; /** Customize descendant properties. */ descendants?: { [ key: string /** ID path of the descendant. */ ]: {} /** Based on the presence of `type`: - `type` is not present = property overrides: the descendant node is updated with the listed properties. - `type` is present = replacement: the descendant node is fully replaced with a new node tree. */; }; [key: string]: any; } export type Child = | Frame | Group | Rectangle | Ellipse | Path | Polygon | Text | Note | Prompt | Context | IconFont | Script | Ref; export type IdPath = string; export interface Document { version: "2.11"; themes?: { [key: string /** RegEx: [^:]+ */]: string[] }; imports?: { [ key: string ]: string /** Value: relative URI of imported .pen file. Key: short alias. */; }; variables?: { [key: string /** RegEx: [^:]+ */]: | { type: "boolean"; value: | BooleanOrVariable | { value: BooleanOrVariable; theme?: Theme }[]; } | { type: "color"; value: ColorOrVariable | { value: ColorOrVariable; theme?: Theme }[]; } | { type: "number"; value: | NumberOrVariable | { value: NumberOrVariable; theme?: Theme }[]; } | { type: "string"; value: | StringOrVariable | { value: StringOrVariable; theme?: Theme }[]; }; }; children: ( | Frame | Group | Rectangle | Ellipse | Polygon | Path | Text | Note | Context | Prompt | IconFont | Script | Ref )[]; }
Last updated on