Advanced · A2
HTML sidebars and dialogs
Alerts and prompts only ask one question at a time. With HtmlService you build a real interface (buttons, lists, forms) inside the Sheet. The key idea is that your code now lives in two places that talk to each other.
Server and client
🖥️ Server: Code.gs
Runs on Google's servers. Can use SpreadsheetApp, GmailApp and the rest. Can't see the page.
🌐 Client: Sidebar.html
Runs in the user's browser. Can change the page and react to clicks. Can't touch the sheet.
They talk through one bridge:
browser Google
─────── ──────
google.script.run ──▶ getStrugglingStudents()
.withSuccessHandler(showList) reads the sheet
◀── return ['Mint (45)']
showList(['Mint (45)']) Everything in this chapter, and the web apps in A3, is built on that picture. If something doesn't work, first ask: which side is this code running on?
Open a sidebar from a menu
In the Marks project, add HTML files with + → HTML. Name them Sidebar and Dialog (the editor adds .html).
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('🎓 Class tools')
.addItem('Open student panel', 'showSidebar')
.addItem('Add a student…', 'showDialog')
.addToUi();
}
function showSidebar() {
const html = HtmlService.createHtmlOutputFromFile('Sidebar').setTitle('Student panel');
SpreadsheetApp.getUi().showSidebar(html);
}
function showDialog() {
const html = HtmlService.createHtmlOutputFromFile('Dialog').setWidth(360).setHeight(260);
SpreadsheetApp.getUi().showModalDialog(html, 'Add a student');
} | Show it as | Method | Notes |
|---|---|---|
| Sidebar | ui.showSidebar(html) | 300 px wide, on the right, stays open while you work |
| Modal dialog | ui.showModalDialog(html, title) | Blocks the sheet until closed |
| Modeless dialog | ui.showModelessDialog(html, title) | Floats; the sheet still works |
createHtmlOutputFromFile('Sidebar') loads the HTML file. createHtmlOutput('<p>Hi</p>')
builds one from a string, which is handy for a one-line message.
The sidebar's HTML
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body { font-family: Arial, sans-serif; padding: 12px; }
button { padding: 8px 12px; margin-top: 8px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h3>Students below 50</h3>
<button id="load">Check now</button>
<ul id="list"></ul>
<p id="status"></p>
<script>
document.getElementById('load').addEventListener('click', () => {
document.getElementById('status').textContent = 'Loading…';
google.script.run
.withSuccessHandler(showList)
.withFailureHandler((err) => {
document.getElementById('status').textContent = 'Error: ' + err.message;
})
.getStrugglingStudents(); // runs the server function in Code.gs
});
function showList(names) {
const ul = document.getElementById('list');
ul.innerHTML = '';
names.forEach((n) => {
const li = document.createElement('li');
li.textContent = n; // textContent, never innerHTML, for data
ul.appendChild(li);
});
document.getElementById('status').textContent = names.length + ' found';
}
</script>
</body>
</html> The server side
// Called from the sidebar with google.script.run. Whatever it returns
// is passed to the success handler in the browser.
function getStrugglingStudents() {
const rows = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('Marks').getDataRange().getValues().slice(1);
return rows.filter((r) => r[2] < 50).map((r) => `${r[0]} (${r[2]})`);
}
// Called from the dialog form.
function addStudent(form) {
const name = String(form.name).trim();
const mark = Number(form.mark);
if (!name) throw new Error('Please type a name.');
if (!(mark >= 0 && mark <= 100)) throw new Error('Mark must be 0–100.');
SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Marks').appendRow([name, form.room, mark]);
return `${name} added`;
} Click Check now in the sidebar. The browser asks the server, the server reads the sheet, and the list fills in.
google.script.run.getStrugglingStudents()calls the server function with that name.- The call is asynchronous: the page keeps working while it waits. The answer arrives in the
withSuccessHandlerfunction. - If the server throws an error,
withFailureHandlergets it, so the user sees a message instead of nothing. - Return plain data only: strings, numbers, booleans, arrays and plain objects. A Date or a Range can't cross the bridge.
Data is not HTML
The list uses li.textContent = n. With innerHTML, a "name" like
<img src=x onerror=…> typed into the sheet would run as code in your sidebar. Show data with
textContent, always.
Collect input in a dialog
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body { font-family: Arial, sans-serif; }
label { display: block; margin-top: 8px; }
.error { color: #c5221f; }
</style>
</head>
<body>
<form id="f">
<label>Name <input name="name" required></label>
<label>Room
<select name="room">
<option>M.5/1</option><option>M.5/2</option><option>M.5/3</option>
</select>
</label>
<label>Mark <input name="mark" type="number" min="0" max="100"></label>
<button type="submit">Add</button>
<p id="msg"></p>
</form>
<script>
document.getElementById('f').addEventListener('submit', (e) => {
e.preventDefault(); // stop the page from reloading
const msg = document.getElementById('msg');
google.script.run
.withSuccessHandler(() => google.script.host.close()) // close the dialog
.withFailureHandler((err) => { msg.className = 'error'; msg.textContent = err.message; })
.addStudent(e.target); // a <form> is sent as {name, room, mark}
});
</script>
</body>
</html>
Passing the <form> element itself to a server function sends its fields as an object:
{ name: "Tee", room: "M.5/2", mark: "90" }. The addStudent function in the server
code above checks the input and throws an Error if it's bad. The message appears in the dialog in
red. On success, google.script.host.close() closes the dialog.
Check on the server too
The browser's required and min/max help honest users, but anyone can bypass them. The real checks belong in addStudent on the server, where the data is written.
Build the page on the server
Instead of loading data after the page opens, a template can put the data into the HTML before it is sent. You met scriptlets in I4's email template; here they draw a table:
// Server-side templating: build the HTML with data before it reaches the browser.
function showReport() {
const t = HtmlService.createTemplateFromFile('Report');
t.rows = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('Marks').getDataRange().getDisplayValues();
const html = t.evaluate().setWidth(500).setHeight(400);
SpreadsheetApp.getUi().showModalDialog(html, 'Marks report');
}
// Lets one HTML file pull in another: <?!= include('Styles') ?>
function include(name) {
return HtmlService.createHtmlOutputFromFile(name).getContent();
} <!DOCTYPE html>
<html>
<head>
<base target="_top">
<?!= include('Styles') ?>
</head>
<body>
<table>
<? rows.forEach((row, i) => { ?>
<tr class="<?= i === 0 ? 'head' : '' ?>">
<? row.forEach((cell) => { ?>
<td><?= cell ?></td>
<? }); ?>
</tr>
<? }); ?>
</table>
</body>
</html> <style>
body { font-family: Arial, sans-serif; }
table { border-collapse: collapse; width: 100%; }
td { border: 1px solid #dadce0; padding: 6px; }
tr.head td { background: #e8f0fe; font-weight: bold; }
</style> t.rows = …makesrowsa variable inside the template.<? … ?>runs code such as loops;<?= … ?>prints an escaped value.<?!= include('Styles') ?>pastes another file in raw, so CSS and shared parts live in their own files.
Which approach? A template is simplest when the data is ready at the start. google.script.run
is better for buttons, live refreshes and anything the user triggers later. Most real tools use both.
Practice
1. Row counter Easy
A sidebar with a Refresh button that shows how many rows the active tab has.
Show a solution
// Code.gs
function showCounter() {
SpreadsheetApp.getUi().showSidebar(HtmlService.createHtmlOutputFromFile('Counter').setTitle('Row counter'));
}
function countRows() {
return SpreadsheetApp.getActiveSheet().getLastRow();
} <!-- Counter.html -->
<!DOCTYPE html>
<html>
<head><base target="_top"></head>
<body style="font-family: Arial, sans-serif">
<p>This tab has <b id="n">?</b> rows.</p>
<button onclick="google.script.run.withSuccessHandler(n => document.getElementById('n').textContent = n).countRows()">
Refresh
</button>
</body>
</html> 2. Room filter Medium
Add a drop-down of rooms to the student panel. Pass the chosen room to the server (google.script.run.getRoom('M.5/3')) and list only that room's students with their marks.
3. Email from the sidebar Challenge
Add a "Email this student" button next to each name. It calls a server function that sends a pre-written reminder, then shows ✓ next to the name. Disable the button while waiting so it can't be double-clicked.