#76 "Let's Make Web Apps Look Great on Smartphones"
I'm Yasuken from Perch LLC in Karatsu City, Saga Prefecture.
For the 76th installment of the GAS 100 Knock series, we are covering "Responsive Design for Web Apps."
When I created the expense application web app in the previous #75, I was discouraged because it looked so small on a smartphone that it was unusable. GAS web apps surprisingly have many pitfalls when it comes to mobile support, and it's common for them to look difficult to use on mobile even if they work fine on PC.

This time, I've navigated all those pitfalls to create a task management app with a screen that doesn't break on mobile, tablet, or PC.
Today's Topic
Add responsive support to an existing web app to achieve a screen that doesn't break on mobile, tablet, or PC.
There are four conditions to meet.
Correctly adding the meta viewport tag
Switching layouts using CSS `@media (max-width: 768px)`
Converting table displays to a card-based UI on mobile
Improving UX with a loading spinner
The first pitfall: meta tags don't work even when written in HTML
When opening a GAS web app on a smartphone, the entire page shrinks and the text becomes tiny—this was the first wall I hit.
When I investigated the cause, I found that `<meta>` tags written directly in GAS HTML files are ignored. This is a specification.
<!-- これはGASでは効かない -->
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>Because GAS manages headers independently when outputting HTML, the meta tags inside the HTML file are discarded.
The correct solution: call addMetaTag() on the server side
The correct approach is to use `HtmlOutput.addMetaTag()` inside `doGet()`.
function doGet(e) {
const output = HtmlService.createTemplateFromFile('index').evaluate();
output
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, minimum-scale=1.0')
.setTitle('タスク管理 #76');
return output;
}By adding `minimum-scale=1.0`, you can restrict the page from shrinking below 1x when zoomed out to the minimum. This alone solved the smartphone shrinking issue.
Implementing the table to card switch
A common UI pattern for GAS web apps is to display data in a table. However, tables are difficult to use on smartphone screens because they cause horizontal scrolling. This time, I switched to a card-based UI by hiding the table at the `@media (max-width: 768px)` breakpoint.
The CSS structure looks like this.
/* PC: テーブルを表示、カードを隠す */
.task-table { /* 通常通り */ }
.task-cards { display: none; }
/* モバイル: テーブルを隠して、カードを表示 */
@media (max-width: 768px) {
.task-table { display: none; }
.task-cards { display: block; }
}For the HTML, I simply render the same data in two different formats and switch between them using CSS. Since I am using Vue.js, I only needed to write `v-for` in two places.
<!-- PC: テーブルビュー -->
<table class="task-table">
<tr v-for="task in filteredTasks" :key="task.id">...</tr>
</table>
<!-- モバイル: カードビュー -->
<div class="task-cards">
<div v-for="task in filteredTasks" :key="task.id" class="task-card">...</div>
</div>Mobile card UI: Representing priority with border colors
One trick I used for the card-based UI was to visualize the priority using the left border color.
.task-card.priority-高 { border-left: 4px solid #ef4444; }
.task-card.priority-中 { border-left: 4px solid #f59e0b; }
.task-card.priority-低 { border-left: 4px solid #10b981; }By dynamically assigning a class in Vue.js like `:class="'priority-' + task.priority"`, the appearance changes automatically based on the data. Information that was represented by text badges in the table can now also be conveyed through color in the cards.
Implementing a loading spinner
If nothing is displayed while data is being fetched, users might think the app is broken. I manage a `loading` flag using Vue.js's `ref(true)` and set it to `false` once the fetch is complete.
const loading = ref(true);
function loadTasks() {
loading.value = true;
google.script.run
.withSuccessHandler(result => {
tasks.value = result;
loading.value = false;
})
.withFailureHandler(() => {
loading.value = false;
showToast('読み込みに失敗しました');
})
.getTasks();
}In the HTML, I display the spinner using `v-if="loading"`.
<div v-if="loading" class="spinner-wrap">
<div class="spinner"></div>
</div>The spinner itself is implemented solely with CSS animations. It can be implemented simply without the need for external libraries.
.spinner {
width: 36px;
height: 36px;
border: 3px solid #d1d5db;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
Points where I got stuck
Form layout breakage
The form with "text input + selection + button" that I had arranged in a single line on PC became too narrow on smartphones, making the button impossible to press.
The solution was to use `flex-wrap: wrap` to provide a fallback. I applied `flex-basis: 100%` to the text input to make it occupy the first line, and moved the selection and button to the second line.
@media (max-width: 768px) {
.form-row { flex-wrap: wrap; }
.form-row input[type="text"] { flex-basis: 100%; }
.form-row select, .btn { flex: 1; }
}CSS scope is within the iframe
GAS web apps are deployed within an iframe. Therefore, CSS from the external page does not reach them at all. If you want to use a CSS framework, you must load the CDN within index.html. I struggled for a while trying to apply the parent page's CSS while attempting to use `Bootstrap`.
Summary
What I learned this time
GAS ignores meta tags written directly in HTML → It is essential to call `addMetaTag()` in `doGet()`
Table to card conversion can be achieved by switching `@media` + `display: none/block`
Vue.js `v-if` + `loading` flag makes it easy to implement a loading spinner
GAS web apps are inside an iframe, so external CSS won't reach them; keep all styles self-contained
It is overwhelmingly easier to incorporate mobile support into the design from the start rather than adding it as an afterthought. Next time, #77, we will take on "Let's incorporate AI chat into a web app"!
Taking on GAS 100 Knock #76 "Let's make web apps look great on smartphones"!
I fell into the trap where GAS ignores meta tags written directly in HTML. The correct solution is to call addMetaTag() in doGet(). I also implemented table-to-card switching and a CSS spinner, completing mobile support✨
