Foundations

Building the Chat Widget UI

⏱ 15 min

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.

What you'll learn in this lesson
A minimal, real chat widget in vanilla JavaScript
A toggle button, a message list, and a form, no framework dependency.
Enqueuing it correctly
wp_enqueue_script() and wp_enqueue_style(), not an inline <script> tag in a template file.
Passing server data to the script safely
wp_localize_script() for the REST URL and nonce, never hardcoded into the JS file.
Where this widget will call out to
A stub send function that Lesson 3 replaces with a real fetch() call.
Prerequisites

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.

Enqueuing on every single page load

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();
	}
} )();
What this file deliberately does not do yet
1
No network call
sendMessage() is a placeholder, nothing here talks to your server or any AI provider yet.
2
No session handling
Conversation memory across messages is Lesson 4's problem, not this file's.
3
No accessibility polish beyond the basics
Focus management and ARIA live regions get a full pass in Lesson 8.

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

← Why Front-End AI Is Different From Admin-Facing Agents Wiring the Widget to a WordPress REST Endpoint →