Quick Start
This guide assumes your plugin is currently PHP-only and you want to add your first Plugin Kit web-component screen in the Craft control panel.
What you need is a small frontend build step inside your plugin, plus a way for Craft to load the built files on the CP page where your <pk-*> markup will live.
TIP
Want to skip Vite entirely? See No-Build Step for the script-tag loader path.
Requirements
- Node
>= 20 - a Craft plugin where you can add a frontend folder and an asset bundle
- a CP page, settings screen, utility, or template where you can render
<pk-*>markup
What you are setting up
At a high level, you are connecting four pieces:
- a frontend source folder in your plugin
- a Vite build that turns that source into browser files
- a Craft
AssetBundlethat loads those built files in the CP - Twig (or JS) markup that uses
<pk-*>elements once they have registered
Unlike a React mount, the components upgrade in place. Your template can contain the markup; the JS entry mainly loads tokens and registers the custom elements.
Recommended folder shape
There is no single required folder layout, but a structure like this keeps the PHP side and frontend side easy to understand:
my-plugin/
src/
templates/
web/
assets/
cp/
dist/
src/
my-plugin-cp.ts
package.json
vite.config.tsThe important idea is simple:
src/holds your frontend source filesdist/holds the built files that Craft will publish as CP resources
The examples below assume you are starting from your plugin root:
cd path/to/my-pluginIf your plugin uses a different frontend folder, adjust the paths to match.
1. Create a frontend package
Create and enter the frontend folder from the example structure:
mkdir -p src/web/assets/cp/src
cd src/web/assets/cpFrom my-plugin/src/web/assets/cp, create a package.json and install the dependencies you need:
npm init -y
npm install @verbb/plugin-kit-web
npm install -D vite typescript2. Create a simple Vite config
For a first integration, keep the output predictable so your Craft asset bundle can point to fixed filenames.
Create my-plugin/src/web/assets/cp/vite.config.ts:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: 'src/my-plugin-cp.ts',
output: {
entryFileNames: 'my-plugin-cp.js',
assetFileNames: 'my-plugin-cp[extname]',
},
},
},
});This keeps the first setup easy to follow. Once everything is working, you can move to hashed filenames or a manifest-based approach if that fits your plugin better.
You do not need a Craft-specific Vite plugin just to get started. Any Vite setup that outputs browser-ready JS and CSS files will work. If you already have a Craft-focused Vite workflow you like, you can keep using it.
3. Create your CP entry file
Create my-plugin/src/web/assets/cp/src/my-plugin-cp.ts. Import the stylesheet once, then short family entries for each control you use — each import registers that custom element (and its companions, e.g. dropdown menu + items):
import '@verbb/plugin-kit-web/plugin-kit.css';
import '@verbb/plugin-kit-web/components/button.js';
import '@verbb/plugin-kit-web/components/dropdown-menu.js';
import '@verbb/plugin-kit-web/components/icon.js';
// Only the glyphs your Twig `<pk-icon icon="…">` tags need
import { registerIcons, plus, gear } from '@verbb/plugin-kit-icons';
registerIcons({ plus, gear });This entry file does three important things:
- loads design tokens and FOUCE in one stylesheet (
plugin-kit.css) - registers just the custom element families your page needs so Twig markup can upgrade in place
- keeps TipTap, CodeMirror, and other heavy surfaces out of the bundle until you import those families
If you use <pk-icon>, register each glyph (or import @verbb/plugin-kit-icons/all.js when you want the full curated set). HTML uses kebab-case names (icon="arrow-up"); JS named exports are camelCase (arrowUp) and normalize on register.
Need a typed class? Import the deep path instead:
import { PkButton } from '@verbb/plugin-kit-web/components/button/pk-button.js';Avoid importing from the package root (@verbb/plugin-kit-web) or @verbb/plugin-kit-web/register in production CP bundles unless you intentionally want the full library.
4. Build the frontend files
From my-plugin/src/web/assets/cp, run:
npx vite buildAfter that, your frontend folder should contain built files in my-plugin/src/web/assets/cp/dist, such as my-plugin-cp.js and my-plugin-cp.css.
5. Register the built files in Craft
Create an asset bundle class in your plugin, for example my-plugin/src/web/assets/cp/MyPluginCpAsset.php:
<?php
namespace mynamespace\myplugin\web\assets\cp;
use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;
class MyPluginCpAsset extends AssetBundle
{
public function init(): void
{
$this->sourcePath = '@mynamespace/myplugin/web/assets/cp/dist';
$this->depends = [
CpAsset::class,
];
$this->js = ['my-plugin-cp.js'];
$this->css = ['my-plugin-cp.css'];
parent::init();
}
}Update the namespace, alias, and class name to match your plugin.
The important part is that sourcePath points at the built frontend output, and the js and css arrays match the filenames your Vite build created.
6. Render markup in the CP
On the Craft side, register the asset bundle and output the <pk-*> markup your entry file will upgrade. For example, add this to the template for your CP page, such as my-plugin/src/templates/settings.twig:
{% do view.registerAssetBundle('mynamespace\\myplugin\\web\\assets\\cp\\MyPluginCpAsset') %}
<div class="pane">
<h2>My first Plugin Kit screen</h2>
<pk-dropdown-menu>
<pk-button slot="trigger" with-caret>Actions</pk-button>
<pk-dropdown-item value="duplicate">Duplicate</pk-dropdown-item>
<pk-dropdown-item value="delete">Delete</pk-dropdown-item>
</pk-dropdown-menu>
</div>At this point, the flow is:
- Craft renders the page (including your
<pk-*>tags) - Craft loads your asset bundle CSS and JS
- tokens and FOUCE apply before/as components upgrade
- your entry imports register the
<pk-*>elements you use - the markup becomes interactive Plugin Kit components
7. Passing data from PHP
Once registration is working, wire PHP values into <pk-*> attributes the same way you would any other HTML — escape attribute values in Twig:
{% set deleteLabel = settings.deleteLabel ?? 'Delete' %}
<pk-dropdown-menu>
<pk-button slot="trigger" with-caret {% if not settings.canEdit %}disabled{% endif %}>
Actions
</pk-button>
<pk-dropdown-item value="duplicate">Duplicate</pk-dropdown-item>
<pk-dropdown-item
value="delete"
{% if not settings.canDelete %}disabled{% endif %}
>{{ deleteLabel|e }}</pk-dropdown-item>
</pk-dropdown-menu>Using Craft’s tag helpers
Craft’s tag() function and {% tag %} tag are optional ergonomics for the same markup. Attributes are HTML-encoded for you — no manual |e / |e('html_attr') on each value.
Nested menus are a good fit for {% tag %}:
{% tag 'pk-dropdown-menu' %}
{{ tag('pk-button', {
slot: 'trigger',
withCaret: true,
text: 'Actions',
disabled: not settings.canEdit,
}) }}
{{ tag('pk-dropdown-item', {
value: 'duplicate',
text: 'Duplicate',
}) }}
{{ tag('pk-dropdown-item', {
value: 'delete',
text: settings.deleteLabel ?? 'Delete',
disabled: not settings.canDelete,
}) }}
{% endtag %}Waiting for components from JavaScript
If your entry script reads properties, focuses elements, or attaches listeners on first load, wait until the tags are defined:
import { allDefined } from '@verbb/plugin-kit-web/plugin-kit';
await allDefined();Framework adapters
Prefer web components directly when your CP UI is otherwise vanilla JS. If you are already on React or Vue, use the adapter packages instead — they wrap these same elements:
Next steps
- Prefer no Vite at all? See No-Build Step for the script-tag / loader path.
- Read Tokens & CSS for shadow DOM, tokens, and styling details.
- Read Reducing FOUCE to understand why FOUCE CSS loads before your components.
- Browse the component reference for props, events, and slots.
- When a screen grows into a fuller client app, revisit the React or Vue guides if a framework starts to make more sense than vanilla DOM code.