Before there’s anything to wire up, there needs to be a widget: a toggle button, a
message panel, an input, and somewhere to render the conversation. This lesson builds
that in plain JavaScript, no build step or framework required, and enqueues it the
correct WordPress way rather than dropping a <script> tag into a template. The next
lesson connects it to a real backend, this one is entirely about the UI shell.
Lesson 1’s architecture principles, and a WordPress plugin (or must-use plugin) you can add PHP and static asset files to.
Step 1: the file layout
This widget lives in a small plugin with a predictable structure:
my-plugin/
my-plugin.php
inc/
enqueue-chat-widget.php
assets/
js/
chat-widget.js
css/
chat-widget.css
Nothing here is chat-specific magic, it’s the same shape any plugin enqueuing its own script and stylesheet would use.
Step 2: enqueue the script and stylesheet
// File: inc/enqueue-chat-widget.php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_script(
'site-chat-widget',
plugins_url( 'assets/js/chat-widget.js', __DIR__ ),
array(),
'1.0.0',
true
);
wp_enqueue_style(
'site-chat-widget',
plugins_url( 'assets/css/chat-widget.css', __DIR__ ),
array(),
'1.0.0'
);
wp_localize_script( 'site-chat-widget', 'siteChatWidget', array(
'restUrl' => esc_url_raw( rest_url( 'my-plugin/v1/chat' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
} );
wp_localize_script() is what makes the REST URL and nonce available to the JS file as
a plain global object, siteChatWidget, instead of hardcoding a URL into the script or
scraping it out of the page some other way. The nonce itself isn’t a login credential,
it’s the basic request-legitimacy check Lesson 3 verifies server-side, more on exactly
what it does and doesn’t prove there.
This example enqueues the widget site-wide on every front-end request, fine for getting something working, but it means every page pays the cost of loading the script even if a visitor never opens the widget. Lesson 8 revisits this and switches to enqueuing only where a shortcode or block actually places the widget.
Step 3: the widget markup and behavior
// File: assets/js/chat-widget.js
( function () {
function createWidget() {
var root = document.createElement( 'div' );
root.id = 'site-chat-widget';
root.innerHTML =
'<button id="site-chat-toggle" aria-label="Open chat assistant">Chat</button>' +
'<div id="site-chat-panel" hidden>' +
' <div id="site-chat-messages"></div>' +
' <form id="site-chat-form">' +
' <input id="site-chat-input" type="text" placeholder="Ask a question" autocomplete="off" />' +
' <button type="submit">Send</button>' +
' </form>' +
'</div>';
document.body.appendChild( root );
var toggle = document.getElementById( 'site-chat-toggle' );
var panel = document.getElementById( 'site-chat-panel' );
var form = document.getElementById( 'site-chat-form' );
var input = document.getElementById( 'site-chat-input' );
var messages = document.getElementById( 'site-chat-messages' );
toggle.addEventListener( 'click', function () {
panel.hidden = ! panel.hidden;
if ( ! panel.hidden ) {
input.focus();
}
} );
form.addEventListener( 'submit', function ( event ) {
event.preventDefault();
var text = input.value.trim();
if ( ! text ) {
return;
}
appendMessage( 'visitor', text );
input.value = '';
sendMessage( text );
} );
function appendMessage( role, text ) {
var bubble = document.createElement( 'div' );
bubble.className = 'site-chat-message site-chat-message--' + role;
bubble.textContent = text;
messages.appendChild( bubble );
messages.scrollTop = messages.scrollHeight;
}
function sendMessage( text ) {
// Stub for now, Lesson 3 replaces this with a real fetch() call
// to the REST endpoint registered server-side.
appendMessage( 'assistant', 'Thinking...' );
}
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', createWidget );
} else {
createWidget();
}
} )();
Step 4: a minimal stylesheet
/* File: assets/css/chat-widget.css */
#site-chat-widget {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 9999;
font-family: sans-serif;
}
#site-chat-panel {
width: 320px;
max-height: 420px;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #ccc;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
margin-bottom: 8px;
}
#site-chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.site-chat-message {
margin-bottom: 8px;
padding: 8px 10px;
border-radius: 8px;
max-width: 85%;
}
.site-chat-message--visitor {
background: #e8f0fe;
margin-left: auto;
}
.site-chat-message--assistant {
background: #f1f1f1;
}
Test it: confirm the widget renders
Load any front-end page and open your browser’s console:
document.getElementById( 'site-chat-widget' ) !== null
That should return true. Click the toggle button, confirm the panel opens, type a
message, and confirm it appears as a visitor bubble followed by a static “Thinking…”
placeholder, that placeholder is exactly what Lesson 3 replaces with a real reply.
Recap
The widget is a small, dependency-free piece of vanilla JavaScript, a toggle button, a
message panel, and a form, enqueued the standard WordPress way with wp_enqueue_script()
and wp_enqueue_style(). wp_localize_script() hands the script its REST URL and nonce
without hardcoding either into the file. Nothing here calls out to an AI provider yet,
sendMessage() is deliberately a stub, that’s exactly what the next lesson replaces
with a real, server-proxied call.
Resources & further reading
- wp_enqueue_script() function reference, developer.wordpress.org
- wp_localize_script() function reference, developer.wordpress.org
- wp_create_nonce() function reference, developer.wordpress.org