├── .gitignore ├── README.md ├── app ├── api │ └── predictions │ │ ├── [id] │ │ └── route.js │ │ └── route.js ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── Panel.tsx └── ui │ ├── button.tsx │ ├── input.tsx │ ├── select.tsx │ └── textarea.tsx ├── eslint.config.mjs ├── lib ├── figmaAPI.ts ├── types.ts └── utils.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── plugin ├── .gitignore ├── code.ts ├── manifest.json └── tsconfig.json ├── postcss.config.mjs ├── public └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | This is an Figma AI plugin template that [demonstrates AI responses](https://x.com/lichinlin/status/1892350882190098840) inside of a Figma plugin. This template shows: 4 | 5 | - Securely storing Replicate keys / prompts on a server 6 | - A fully functional React iframe using tailwind, shadcn, and Next.js 7 | - Deploying your plugin to production 8 | - Accessing the Figma API directly from the iframe 9 | 10 | 11 | ## Getting Started 12 | 13 | you need to store you Replicate API key in the `.env.local` file. You can get an API key from the [API token page](https://replicate.com/account/api-tokens). Create a `.env.local` file in the root of this project and add your API key: 14 | 15 | ```bash 16 | REPLICATE_API_TOKEN=*** 17 | ``` 18 | 19 | Then, run the development server: 20 | 21 | ```bash 22 | npm i 23 | npm run dev 24 | ``` 25 | 26 | You can then open up the Figma desktop app and import a plugin from the manifest file in this project. You can right click on the canvas and navigate to `Plugins > Development > Import plugin from manifest...` and select the `manifest.json` in `{path to this project}/plugin/manifest.json`. 27 | 28 | ![Image showing how to import from manifest](https://static.figma.com/uploads/dcfb742580ad1c70338f1f9670f70dfd1fd42596) 29 | 30 | ## Editing this template 31 | 32 | The main files you'll want to edit are: 33 | 34 | - `app/page.tsx`: will let you update the plugin `iframe`. The page auto-updates as you edit the file and will let you update the user interface of your plugin. 35 | - `app/predictions/route.ts`: This is the "server" of the plugin and is what talks to Replicate AI. This is where you can update the prompt that you are sending to the server. 36 | - `plugin/manifest.json`: this is the [manifest file](https://www.figma.com/plugin-docs/manifest/) that will let you update the permissions and editor types of your plugin. 37 | 38 | ## Publishing your plugin 39 | 40 | In this example we will be publishing the Next.js app to [Vercel](https://vercel.com/). You can also publish to any other hosting provider that supports Next.js. 41 | 42 | 1. If you haven't already, push your code to a git repo on GitHub. 43 | 2. Create an account on Vercel and connect your GitHub account. 44 | 3. Deploy your app to Vercel. You can follow the guide [here](https://vercel.com/docs/concepts/deployments/git). 45 | 4. While deploying make sure to set the environment variable `REPLICATE_API_TOKEN` to your Replicate Token. 46 | 5. Once your app is deployed you can update the `siteURL` section of your `package.json` file to point to the deployed URL. It will look something like `https://your-site-here.vercel.app/` 47 | 48 | ```json 49 | "config": { 50 | "siteURL": "https://your-site-here.vercel.app/" 51 | } 52 | ``` 53 | 54 | 6. Run `npm run build` to create the production build of your plugin that points to your deployed URL. 55 | 7. Test your plugin locally and make sure that it works after pointing to vercel. 56 | 8. [Publish your plugin to community](https://help.figma.com/hc/en-us/articles/360042293394-Publish-plugins-to-the-Figma-Community) 57 | 9. After publishing to community your plugin will update automatically when you push to your git repo. 58 | 59 | ## figmaAPI 60 | 61 | This template includes a `figmaAPI` helper at `@/lib/figmaAPI` that lets you run plugin code from inside of the iframe. This is 62 | useful for avoiding the iframe <-> plugin postMessage API and reduces the amount of code you need to write. 63 | 64 | **Example:** 65 | 66 | ```ts 67 | import { figmaAPI } from "@/lib/figmaAPI"; 68 | 69 | const nodeId = "0:2"; 70 | 71 | const result = await figmaAPI.run( 72 | (figma, { nodeId }) => { 73 | return figma.getNodeById(nodeId)?.name; 74 | }, 75 | // Any variable you want to pass to the function must be passed as a parameter. 76 | { nodeId } 77 | ); 78 | 79 | console.log(result); // "Page 1" 80 | ``` 81 | 82 | A few things to note about this helper: 83 | 84 | 1. The code cannot reference any variables outside of the function unless they are passed as a parameter to the second argument. This is 85 | because the code is stringified and sent to the plugin, and the plugin 86 | evals it. The plugin has no access to the variables in the iframe. 87 | 2. The return value of the function must be JSON serializable. This is 88 | because the result is sent back to the iframe via postMessage, which only 89 | supports JSON. 90 | 91 | ## Learn More 92 | 93 | - [Figma plugin reference](https://github.com/figma/ai-plugin-template) 94 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 95 | - [Shadcn + Tailwind4](https://ui.shadcn.com/docs/tailwind-v4) 96 | - [Figma Plugin API](https://www.figma.com/plugin-docs/) - learn about the Figma plugin API. 97 | - [Replicate API](https://replicate.com/docs/topics/models/run-a-model) - learn about GPT APIs. 98 | - [Replicate web app example docs](https://replicate.com/docs/guides/nextjs) 99 | -------------------------------------------------------------------------------- /app/api/predictions/[id]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import Replicate from "replicate"; 3 | 4 | const replicate = new Replicate({ 5 | auth: process.env.REPLICATE_API_TOKEN, 6 | }); 7 | 8 | export async function GET(request, context) { 9 | const { id } = await context.params; 10 | const prediction = await replicate.predictions.get(id); 11 | 12 | if (prediction?.error) { 13 | return NextResponse.json({ detail: prediction.error }, { status: 500 }); 14 | } 15 | 16 | return NextResponse.json(prediction); 17 | } 18 | -------------------------------------------------------------------------------- /app/api/predictions/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import Replicate from "replicate"; 3 | 4 | const replicate = new Replicate({ 5 | auth: process.env.REPLICATE_API_TOKEN, 6 | }); 7 | 8 | // In production and preview deployments (on Vercel), the VERCEL_URL environment variable is set. 9 | // In development (on your local machine), the NGROK_HOST environment variable is set. 10 | const WEBHOOK_HOST = process.env.VERCEL_URL 11 | ? `https://${process.env.VERCEL_URL}` 12 | : process.env.NGROK_HOST; 13 | 14 | export async function POST(request) { 15 | if (!process.env.REPLICATE_API_TOKEN) { 16 | throw new Error( 17 | "The REPLICATE_API_TOKEN environment variable is not set. See README.md for instructions on how to set it." 18 | ); 19 | } 20 | 21 | const { prompt, style } = await request.json(); 22 | 23 | const options = { 24 | model: "recraft-ai/recraft-20b-svg", 25 | input: { 26 | prompt, 27 | size: "1024x1024", 28 | style, 29 | }, 30 | }; 31 | 32 | if (WEBHOOK_HOST) { 33 | options.webhook = `${WEBHOOK_HOST}/api/webhooks`; 34 | options.webhook_events_filter = ["start", "completed"]; 35 | } 36 | 37 | // A prediction is the result you get when you run a model, including the input, output, and other details 38 | const prediction = await replicate.predictions.create(options); 39 | 40 | if (prediction?.error) { 41 | return NextResponse.json({ detail: prediction.error }, { status: 500 }); 42 | } 43 | 44 | return NextResponse.json(prediction, { status: 201 }); 45 | } 46 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lichin-lin/figma-text-to-icon/3da00969883c9aa512b7eec252293f2662606d6b/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "tailwindcss-animate"; 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | :root { 6 | --background: hsl(0 0% 100%); 7 | --foreground: hsl(0 0% 3.9%); 8 | --card: hsl(0 0% 100%); 9 | --card-foreground: hsl(0 0% 3.9%); 10 | --popover: hsl(0 0% 100%); 11 | --popover-foreground: hsl(0 0% 3.9%); 12 | --primary: hsl(0 0% 9%); 13 | --primary-foreground: hsl(0 0% 98%); 14 | --secondary: hsl(0 0% 96.1%); 15 | --secondary-foreground: hsl(0 0% 9%); 16 | --muted: hsl(0 0% 96.1%); 17 | --muted-foreground: hsl(0 0% 45.1%); 18 | --accent: hsl(0 0% 96.1%); 19 | --accent-foreground: hsl(0 0% 9%); 20 | --destructive: hsl(0 84.2% 60.2%); 21 | --destructive-foreground: hsl(0 0% 98%); 22 | --border: hsl(0 0% 89.8%); 23 | --input: hsl(0 0% 89.8%); 24 | --ring: hsl(0 0% 3.9%); 25 | --chart-1: hsl(12 76% 61%); 26 | --chart-2: hsl(173 58% 39%); 27 | --chart-3: hsl(197 37% 24%); 28 | --chart-4: hsl(43 74% 66%); 29 | --chart-5: hsl(27 87% 67%); 30 | --radius: 0.6rem; 31 | } 32 | 33 | .dark { 34 | --background: hsl(0 0% 3.9%); 35 | --foreground: hsl(0 0% 98%); 36 | --card: hsl(0 0% 3.9%); 37 | --card-foreground: hsl(0 0% 98%); 38 | --popover: hsl(0 0% 3.9%); 39 | --popover-foreground: hsl(0 0% 98%); 40 | --primary: hsl(0 0% 98%); 41 | --primary-foreground: hsl(0 0% 9%); 42 | --secondary: hsl(0 0% 14.9%); 43 | --secondary-foreground: hsl(0 0% 98%); 44 | --muted: hsl(0 0% 14.9%); 45 | --muted-foreground: hsl(0 0% 63.9%); 46 | --accent: hsl(0 0% 14.9%); 47 | --accent-foreground: hsl(0 0% 98%); 48 | --destructive: hsl(0 62.8% 30.6%); 49 | --destructive-foreground: hsl(0 0% 98%); 50 | --border: hsl(0 0% 14.9%); 51 | --input: hsl(0 0% 14.9%); 52 | --ring: hsl(0 0% 83.1%); 53 | --chart-1: hsl(220 70% 50%); 54 | --chart-2: hsl(160 60% 45%); 55 | --chart-3: hsl(30 80% 55%); 56 | --chart-4: hsl(280 65% 60%); 57 | --chart-5: hsl(340 75% 55%); 58 | } 59 | 60 | @theme inline { 61 | --color-background: var(--background); 62 | --color-foreground: var(--foreground); 63 | --color-card: var(--card); 64 | --color-card-foreground: var(--card-foreground); 65 | --color-popover: var(--popover); 66 | --color-popover-foreground: var(--popover-foreground); 67 | --color-primary: var(--primary); 68 | --color-primary-foreground: var(--primary-foreground); 69 | --color-secondary: var(--secondary); 70 | --color-secondary-foreground: var(--secondary-foreground); 71 | --color-muted: var(--muted); 72 | --color-muted-foreground: var(--muted-foreground); 73 | --color-accent: var(--accent); 74 | --color-accent-foreground: var(--accent-foreground); 75 | --color-destructive: var(--destructive); 76 | --color-destructive-foreground: var(--destructive-foreground); 77 | --color-border: var(--border); 78 | --color-input: var(--input); 79 | --color-ring: var(--ring); 80 | --color-chart-1: var(--chart-1); 81 | --color-chart-2: var(--chart-2); 82 | --color-chart-3: var(--chart-3); 83 | --color-chart-4: var(--chart-4); 84 | --color-chart-5: var(--chart-5); 85 | --radius-sm: calc(var(--radius) - 4px); 86 | --radius-md: calc(var(--radius) - 2px); 87 | --radius-lg: var(--radius); 88 | --radius-xl: calc(var(--radius) + 4px); 89 | } 90 | 91 | @layer base { 92 | * { 93 | @apply border-border outline-ring/50; 94 | } 95 | body { 96 | @apply bg-background text-foreground; 97 | } 98 | } 99 | 100 | html { 101 | width: 100vw; 102 | height: 100vh; 103 | background: hsl(0 0% 3.9%); 104 | } 105 | 106 | * { 107 | /* --font-geist-sans or --font-geist-mono */ 108 | font-family: var(--font-geist-mono); 109 | } 110 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "Create Next App", 17 | description: "Generated by create next app", 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import Panel from "@/components/Panel"; 5 | 6 | export default function Home() { 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectValue, 4 | SelectTrigger, 5 | SelectContent, 6 | SelectItem, 7 | } from "./ui/select"; 8 | import React from "react"; 9 | import { Textarea } from "./ui/textarea"; 10 | import { Button } from "./ui/button"; 11 | import { figmaAPI } from "@/lib/figmaAPI"; 12 | 13 | function Panel() { 14 | const [loading, setLoading] = React.useState(false); 15 | const [prompt, setPrompt] = React.useState(""); 16 | const [style, setStyle] = React.useState("vector_illustration"); 17 | const [output, setOutput] = React.useState(null); 18 | const [svgContent, setSvgContent] = React.useState(""); 19 | 20 | // pass the result to figma 21 | const onPasteSVGIntoFigma = async () => { 22 | const result = await figmaAPI.run((figma, params) => { 23 | const { svg } = params; 24 | const newSVGNode = figma.createNodeFromSvg(svg); 25 | figma.currentPage.appendChild(newSVGNode); 26 | figma.currentPage.selection = [newSVGNode]; 27 | return { nodeId: newSVGNode.id } 28 | }, { svg: svgContent }); 29 | 30 | console.log(`nodeId: ${result.nodeId}`); 31 | }; 32 | 33 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 34 | const getPrediction = async () => { 35 | setLoading(true); 36 | setOutput(null); 37 | const response = await fetch(`/api/predictions`, { 38 | method: "POST", 39 | headers: { 40 | "Content-Type": "application/json", 41 | }, 42 | body: JSON.stringify({ 43 | prompt, 44 | style, 45 | }), 46 | }); 47 | let prediction = await response.json(); 48 | if (response.status !== 201) { 49 | alert("Failed to create a prediction"); 50 | return; 51 | } 52 | 53 | while ( 54 | prediction.status !== "succeeded" && 55 | prediction.status !== "failed" 56 | ) { 57 | await sleep(1000); 58 | const response = await fetch("/api/predictions/" + prediction.id); 59 | prediction = await response.json(); 60 | if (response.status !== 200) { 61 | return; 62 | } 63 | console.log(prediction.logs); 64 | 65 | if (prediction.status === "succeeded") { 66 | setLoading(false); 67 | setOutput(prediction.output); 68 | 69 | const svgResponse = await fetch(prediction.output); 70 | const svgText = await svgResponse.text(); 71 | setSvgContent(svgText); 72 | } 73 | } 74 | }; 75 | 76 | const OPTIONS = [ 77 | "vector_illustration", 78 | "vector_illustration/cartoon", 79 | "vector_illustration/doodle_line_art", 80 | "vector_illustration/engraving", 81 | "vector_illustration/flat_2", 82 | "vector_illustration/kawaii", 83 | "vector_illustration/line_art", 84 | "vector_illustration/line_circuit", 85 | "vector_illustration/linocut", 86 | "vector_illustration/seamless", 87 | "icon", 88 | "icon/broken_line", 89 | "icon/colored_outline", 90 | "icon/colored_shapes", 91 | "icon/colored_shapes_gradient", 92 | "icon/doodle_fill", 93 | "icon/doodle_offset_fill", 94 | "icon/offset_fill", 95 | "icon/outline", 96 | "icon/outline_gradient", 97 | "icon/uneven_fill", 98 | ]; 99 | 100 | return ( 101 |
102 | {/* result */} 103 |
104 | {!loading && !output && ( 105 |
106 | 👇 Fill in the prompt first. 107 |
108 | )} 109 | {loading && !output && ( 110 |
111 | ✨ Generating... 112 |
113 | )} 114 | {!loading && output && svgContent && ( 115 | <> 116 |
120 |
121 | 128 |
129 | 130 | )} 131 |
132 | 133 |
134 |
135 |
136 | 137 |
138 | {/* Prompt */} 139 |
140 | Prompt 141 |