DOM Studio
Vue Mobile UI Components
<DomAppShell>App layouts, navigation, and action sheets for webviews, mobile browsers, and desktop. Built-in safe areas and scrolling, with no demo chrome on small screens.
Overview
Web-first mobile apps
My preference when building mobile apps is to start with CSS and HTML components in a webview. Then, if needed, access native functionality through JavaScript libraries that let you maintain native plugins for Android and iOS, such as Capacitor and similar bridges.
That keeps the interface in familiar web code, with native capabilities added where the app needs them. DOM Studio handles the interface: safe areas, pinned navigation, scrollable screens, touch targets, and motion. The application owns native plugins, routing, and persistence.
Start with the web
Build and test the interface with the HTML, CSS, components, routing, and state your team already uses.
Add native features when needed
A bridge such as Capacitor lets JavaScript call native plugins for iOS and Android while the interface stays in a webview.
Let the layout own scrolling
Use a bounded app shell for pinned navigation and independent content scrolling. Ordinary rows, bars, and sheets also work on desktop.
Make the parent contract explicit
App shell and app stack size themselves by default. Only percentage heights require a sized parent, and nested scroll owners are an explicit choice.
Touch interactions
An inbox with swipe actions
Swipe to mark messages read or archive them, open a conversation in an expandable sheet, and add a reply. Actions have button alternatives and local undo.
Open full-screen inboxStudio Mail
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, nextTick, ref } from 'vue';
import { DomAppBottomNav, DomAppShell, DomBottomSheet, DomButton, DomSwipeActions, DomTextInput, DomTextareaComposer } from '@getdom/studio';
const tab = ref('inbox');
const query = ref('');
const sheetOpen = ref(false);
const selectedId = ref(null);
const snapPoint = ref(0.7);
const announcement = ref('');
const undoAction = ref(null);
const heading = ref(null);
const sheet = ref(null);
const composer = ref(null);
const replyEnd = ref(null);
const drafts = ref({});
const messages = ref([
{ id: 'mara', name: 'Mara Chen', initials: 'MC', time: '10:42', subject: 'A quieter home for our projects', preview: 'The updated layouts are ready for a look.', unread: true, archived: false, deleted: false, paragraphs: ['Hey Alex,', 'I’ve put the updated layouts together. The project overview has a little more breathing room, and the next action stays close to the work.', 'Could you take a look at the activity section? I’d like to keep the useful updates visible without turning it into another inbox.', 'If it feels right, I’ll prepare the remaining screens this afternoon.', 'Thanks, Mara'], replies: [] },
{ id: 'leo', name: 'Leo Martinez', initials: 'LM', time: '09:18', subject: 'Thursday, coffee and a catch-up?', preview: 'There’s a new place just around the corner.', unread: true, archived: false, deleted: false, paragraphs: ['Morning Alex,', 'Are you around on Thursday? There’s a new coffee place near the studio and I thought we could catch up before the planning session.', 'I’m free from 10:30. No agenda, just a proper conversation.', 'Leo'], replies: [] },
{ id: 'nina', name: 'Nina Patel', initials: 'NP', time: 'Yesterday', subject: 'Notes from the customer sessions', preview: 'Three small changes came up in every session.', unread: true, archived: false, deleted: false, paragraphs: ['Hi Alex,', 'I’ve written up the customer sessions. Three things came up repeatedly: clearer project names, a quicker way to find recent work, and fewer steps to change a due date.', 'The encouraging part is that people found the main workflow easy to follow. These are small improvements we can make without changing the structure.', 'Let’s choose one to try this week.', 'Nina'], replies: [] },
{ id: 'owen', name: 'Owen Brooks', initials: 'OB', time: 'Yesterday', subject: 'The studio keys', preview: 'I’ve left the spare set with reception.', unread: false, archived: false, deleted: false, paragraphs: ['Hi Alex,', 'The spare keys are with reception, in an envelope with your name on it. They’re open until six.', 'See you tomorrow, Owen'], replies: [] },
{ id: 'aya', name: 'Aya Wilson', initials: 'AW', time: 'Monday', subject: 'A few photographs from Friday', preview: 'That last bit of afternoon light was worth the wait.', unread: false, archived: false, deleted: false, paragraphs: ['Hi Alex,', 'I’ve picked out a few photographs from Friday. That last bit of afternoon light was worth waiting for.', 'I’ll bring the contact sheet along next week so we can choose the final set together.', 'Aya'], replies: [] },
{ id: 'sam', name: 'Sam Taylor', initials: 'ST', time: 'Monday', subject: 'Workshop follow-up', preview: 'A short list of things we agreed to try.', unread: false, archived: false, deleted: false, paragraphs: ['Hi Alex,', 'Here’s the short version of the workshop: start with the existing workflow, make the next action obvious, and test the changes with a small group first.', 'I’ve booked a room for our follow-up next Tuesday.', 'Sam'], replies: [] },
]);
const icons = {
archive: 'M4 4h16v4H4zM6 8v12h12V8M10 12h4',
mail: 'M4 5h16v14H4zM4 6l8 6 8-6',
delete: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5',
};
const selected = computed(() => messages.value.find((message) => message.id === selectedId.value));
const unreadCount = computed(() => messages.value.filter((message) => message.unread && !message.archived && !message.deleted).length);
const visibleMessages = computed(() => messages.value.filter((message) => !message.deleted && message.archived === (tab.value === 'archive') && `${message.name} ${message.subject} ${message.preview}`.toLowerCase().includes(query.value.trim().toLowerCase())));
const navItems = computed(() => [
{ value: 'inbox', label: 'Inbox', badge: unreadCount.value || undefined, icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="${icons.mail}" stroke-linejoin="round" /></svg>` },
{ value: 'archive', label: 'Archive', icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="${icons.archive}" stroke-linejoin="round" /></svg>` },
]);
/** Supplies a reversible full-swipe read shortcut for the leading edge. */
function startActions(message) {
return [{ value: 'read', label: message.unread ? 'Read' : 'Unread', icon: icons.mail, fullSwipe: true }];
}
/** Supplies archive/restore shortcuts while keeping deletion behind a button. */
function endActions(message) {
return [
{ value: 'archive', label: message.archived ? 'Restore' : 'Archive', icon: icons.archive, variant: 'success', fullSwipe: true },
{ value: 'delete', label: 'Delete', icon: icons.delete, variant: 'danger' },
];
}
/** Opens a message at the reading height and preserves its unsent reply draft. */
function openMessage(message) {
selectedId.value = message.id;
message.unread = false;
snapPoint.value = 0.7;
sheetOpen.value = true;
}
/** Applies a local demo mutation and retains a one-step undo snapshot. */
async function act(message, { action }) {
undoAction.value = { id: message.id, unread: message.unread, archived: message.archived, deleted: message.deleted };
if (action.value === 'read') message.unread = !message.unread;
if (action.value === 'archive') message.archived = !message.archived;
if (action.value === 'delete') message.deleted = true;
announcement.value = action.value === 'read' ? `Marked as ${message.unread ? 'unread' : 'read'}.` : action.value === 'delete' ? 'Message deleted.' : message.archived ? 'Message archived.' : 'Message moved to inbox.';
await nextTick();
if (action.value !== 'read') heading.value?.focus({ preventScroll: true });
}
/** Restores the last changed message, including its original read state. */
function undo() {
const previous = undoAction.value;
const message = messages.value.find((item) => item.id === previous?.id);
if (!message) return;
Object.assign(message, previous);
undoAction.value = null;
announcement.value = 'Change undone.';
}
/** Adds a reply to this local preview, without making any network request. */
async function addReply(value) {
if (!selected.value || !value.trim()) return;
selected.value.replies.push(value.trim());
drafts.value[selected.value.id] = '';
announcement.value = 'Reply added to this preview.';
sheet.value?.expand();
await nextTick();
composer.value?.focus();
replyEnd.value?.scrollIntoView({ block: 'nearest' });
}
</script>
<template>
<DomAppShell hide-bottom-on-keyboard>
<template #top>
<header class="border-b border-border bg-canvas px-5 pb-4 pt-5">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold tracking-widest text-muted-fg uppercase">Studio Mail</p>
<span class="grid size-9 place-items-center rounded-full bg-secondary text-xs font-semibold text-canvas-fg" aria-label="Alex Morgan">AM</span>
</div>
<div class="mt-4 flex items-baseline justify-between gap-3">
<h1 ref="heading" tabindex="-1" class="text-3xl font-semibold tracking-tight outline-none">{{ tab === 'inbox' ? 'Inbox' : 'Archive' }}</h1>
<span class="text-xs text-muted-fg">{{ tab === 'inbox' ? `${unreadCount} unread` : `${visibleMessages.length} messages` }}</span>
</div>
<div class="mt-4"><DomTextInput v-model="query" type="search" chrome="none" placeholder="Search messages" aria-label="Search messages" /></div>
</header>
</template>
<div class="flex items-center justify-between border-b border-border bg-secondary/30 px-5 py-3 text-xs text-muted-fg"><span>{{ tab === 'inbox' ? 'Your conversations' : 'Saved for later' }}</span><span>Swipe for actions</span></div>
<div v-if="visibleMessages.length" class="divide-y divide-border">
<DomSwipeActions v-for="message in visibleMessages" :key="message.id" :label="message.subject" :start-actions="startActions(message)" :end-actions="endActions(message)" full-swipe group="mobile-inbox" @action="act(message, $event)">
<button type="button" :data-message-id="message.id" class="flex min-h-28 w-full gap-3 px-4 py-4 text-start hover:bg-secondary/30 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" @click="openMessage(message)">
<span class="relative mt-0.5 grid size-10 shrink-0 place-items-center rounded-full text-xs font-semibold" :class="message.unread ? 'bg-primary/10 text-primary' : 'bg-secondary text-muted-fg'">{{ message.initials }}<span v-if="message.unread" class="absolute -start-1 top-0 size-2 rounded-full bg-primary ring-2 ring-canvas" aria-label="Unread"></span></span>
<span class="block min-w-0 flex-1">
<span class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-semibold">{{ message.name }}</span><span class="shrink-0 text-[11px] text-muted-fg">{{ message.time }}</span></span>
<span class="mt-1 block truncate text-sm" :class="message.unread ? 'font-semibold text-canvas-fg' : 'font-medium text-canvas-fg'">{{ message.subject }}</span>
<span class="mt-1 block line-clamp-2 text-xs leading-5 text-muted-fg">{{ message.preview }}</span>
</span>
</button>
</DomSwipeActions>
</div>
<div v-else class="grid min-h-56 place-items-center p-8 text-center"><div><p class="text-base font-semibold">{{ query ? 'No matching messages' : 'All clear' }}</p><p class="mt-2 text-sm text-muted-fg">{{ query ? 'Try a name or a few words from the subject.' : tab === 'archive' ? 'Archived conversations will appear here.' : 'Your inbox is clear. Enjoy the quiet.' }}</p></div></div>
<template #bottom>
<div v-if="announcement" class="flex min-h-12 items-center justify-between gap-3 border-t border-border bg-secondary px-5 py-2"><p role="status" class="text-xs text-canvas-fg">{{ announcement }}</p><button v-if="undoAction" type="button" class="min-h-11 px-2 text-sm font-semibold text-primary focus-visible:outline-2 focus-visible:outline-ring" @click="undo">Undo</button></div>
<DomAppBottomNav v-model="tab" :items="navItems" />
</template>
<template #overlay>
<DomBottomSheet ref="sheet" v-model="sheetOpen" v-model:snap-point="snapPoint" :title="selected?.subject || 'Message'" :snap-points="[0, 0.4, 0.7, 0.95]" :initial-snap-point="0.7">
<template v-if="selected" #header><div><p class="truncate text-sm font-semibold">{{ selected.name }}</p><p class="mt-0.5 text-xs text-muted-fg">To you · {{ selected.time }}</p></div></template>
<article v-if="selected" class="space-y-4 pb-3">
<h2 class="text-xl font-semibold leading-snug tracking-tight">{{ selected.subject }}</h2>
<p v-for="(paragraph, index) in selected.paragraphs" :key="index" class="text-sm leading-7 text-muted-fg">{{ paragraph }}</p>
<div v-for="(reply, index) in selected.replies" :key="index" class="rounded-2xl bg-primary/8 p-4"><p class="mb-2 text-xs font-semibold text-primary">You · Just now</p><p class="whitespace-pre-wrap text-sm leading-6">{{ reply }}</p></div>
<p v-if="selected.replies.length" ref="replyEnd" role="status" class="text-xs text-muted-fg">Reply added to this preview.</p>
</article>
<template v-if="selected" #footer>
<DomTextareaComposer ref="composer" v-model="drafts[selected.id]" chrome="none" :aria-label="`Reply to ${selected.name}`" placeholder="Write a reply…" :rows="1" :max-rows="3" :submit-on-enter="false" submit-label="Add reply" @submit="addReply">
<template #submit="{ submit, disabled }"><DomButton size="sm" :disabled="disabled" class="min-h-11" @click="submit">Reply <span aria-hidden="true">↑</span></DomButton></template>
</DomTextareaComposer>
</template>
</DomBottomSheet>
</template>
</DomAppShell>
</template>
Mobile
A working app viewport
On desktop, preview the app inside a phone. On a small screen, the device frame disappears. Use the expand control to try the app across the available display.
Capacitor posture
Keep the UI web-native, then bridge native status bar, haptics, keyboard, camera, and share actions through Capacitor plugins.
Mobile defaults
One scroll container, safe-area-aware chrome, large touch targets, and bottom actions for thumb reach.
Live app shell
Scroll the task list, switch tabs, and open More actions. Navigation stays pinned while the content scrolls.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import {
DomAppBottomNav,
DomAppListItem,
DomAppShell,
DomAppTopBar,
DomActionSheet,
DomTextInput,
DomToggle,
} from '@getdom/studio';
const activeTab = ref('home');
const query = ref('');
const notifications = ref(true);
const sheetOpen = ref(false);
const selectedTask = ref('');
const lastAction = ref('');
const icons = {
home: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-8.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>',
inbox: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 6h16v12H4V6Zm0 8h5l1.5 2h3L15 14h5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" stroke="currentColor" stroke-width="1.8"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.4-2.4 1a7.4 7.4 0 0 0-1.7-1L14.5 3h-4l-.3 3.1a7.4 7.4 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.5a7 7 0 0 0 0 2l-2 1.5 2 3.4 2.4-1a7.4 7.4 0 0 0 1.7 1l.3 3.1h4l.3-3.1a7.4 7.4 0 0 0 1.7-1l2.4 1 2-3.4-2-1.5c.1-.3.1-.7.1-1Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>',
};
const navItems = [
{ value: 'home', label: 'Home', icon: icons.home },
{ value: 'inbox', label: 'Inbox', icon: icons.inbox, badge: '3' },
{ value: 'settings', label: 'Settings', icon: icons.settings },
];
const tasks = [
{ label: 'Sync Capacitor status bar', description: 'Match the native chrome to the DOM Studio theme.', meta: 'Now' },
{ label: 'Review keyboard-aware form', description: 'Keep submit actions visible above the mobile keyboard.', meta: 'Today' },
{ label: 'Ship mobile app shell', description: 'Use safe areas, bottom nav, and one scroll container.', meta: 'Fri' },
{ label: 'Add native share action', description: 'Pair a row action with the Capacitor Share plugin.', meta: 'Fri' },
{ label: 'Tune haptics', description: 'Use light impact feedback for destructive and confirm actions.', meta: 'Next' },
{ label: 'Capture camera flow', description: 'Sketch the image picker and preview confirmation screen.', meta: 'Next' },
];
const filteredTasks = computed(() => {
if (!query.value) return tasks;
return tasks.filter((task) => task.label.toLowerCase().includes(query.value.toLowerCase()));
});
const actions = [
{ label: 'Share project', description: 'Open the native share sheet', value: 'share' },
{ label: 'Duplicate screen', value: 'duplicate' },
{ label: 'Delete draft', value: 'delete', variant: 'danger' },
];
/** Opens the local demo actions for the selected task or the whole project. */
function openActions(task = '') {
selectedTask.value = task;
sheetOpen.value = true;
}
/** Shows the chosen demo action without pretending to call a native plugin. */
function selectAction(action) {
lastAction.value = `${action.label} selected. Connect this action to your application.`;
}
</script>
<template>
<DomAppShell>
<template #top>
<DomAppTopBar :title="activeTab === 'home' ? 'DOM Studio' : activeTab === 'inbox' ? 'Inbox' : 'Settings'" subtitle="Mobile workspace">
<template #trailing>
<button
type="button"
class="grid size-11 place-items-center rounded-full bg-secondary text-canvas-fg active:scale-95"
aria-label="More actions"
@click="openActions()"
>
<svg class="size-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M4 10a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm6 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z" />
</svg>
</button>
</template>
</DomAppTopBar>
</template>
<section v-if="activeTab === 'home'" class="space-y-4 p-4">
<div class="rounded-[1.75rem] bg-primary p-5 text-primary-fg shadow-xl shadow-black/10">
<p class="text-xs font-medium uppercase tracking-[0.16em] opacity-80">Today</p>
<h2 class="mt-2 text-2xl font-semibold tracking-tight">Build mobile primitives.</h2>
<p class="mt-2 text-sm leading-6 opacity-85">A web UI system, tuned for Capacitor screens and native app rhythms.</p>
</div>
<div class="mobile-task-search">
<DomTextInput
v-model="query"
type="search"
placeholder="Search mobile tasks"
aria-label="Search mobile tasks"
/>
</div>
<div class="overflow-hidden rounded-[1.5rem] border border-border bg-canvas">
<DomAppListItem
v-for="task in filteredTasks"
:key="task.label"
:label="task.label"
:description="task.description"
:meta="task.meta"
chevron
@click="openActions(task.label)"
>
<template #icon>
<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="m5 10 3 3 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</template>
</DomAppListItem>
</div>
<div class="overflow-hidden rounded-[1.5rem] border border-border bg-canvas">
<DomAppListItem as="div" label="Push notifications" description="Demo row using an existing DOM Studio toggle.">
<template #trailing>
<DomToggle v-model="notifications" label="Push notifications" chrome="none" />
</template>
</DomAppListItem>
<DomAppListItem label="Native share" description="Pair this row with Capacitor Share." chevron @click="openActions()" />
</div>
</section>
<section v-else-if="activeTab === 'inbox'" class="space-y-4 p-4">
<h2 class="text-lg font-semibold">Needs your attention</h2>
<div class="overflow-hidden rounded-2xl border border-border">
<DomAppListItem v-for="task in tasks.slice(0, 3)" :key="task.label" :label="task.label" :description="task.description" chevron @click="openActions(task.label)" />
</div>
</section>
<section v-else class="space-y-4 p-4">
<h2 class="text-lg font-semibold">Workspace preferences</h2>
<div class="overflow-hidden rounded-2xl border border-border">
<DomAppListItem as="div" label="Push notifications" description="Activity and updates from your workspace.">
<template #trailing><DomToggle v-model="notifications" label="Push notifications" chrome="none" /></template>
</DomAppListItem>
<DomAppListItem label="Project actions" description="Share, duplicate, or delete a draft." chevron @click="openActions()" />
</div>
</section>
<p v-if="lastAction" role="status" class="px-4 pb-4 text-sm text-muted-fg">{{ lastAction }}</p>
<template #bottom>
<DomAppBottomNav v-model="activeTab" :items="navItems" />
</template>
<template #overlay>
<DomActionSheet
v-model="sheetOpen"
:title="selectedTask || 'Project actions'"
description="Use for app commands that should stay near the thumb."
:actions="actions"
@select="selectAction"
/>
</template>
</DomAppShell>
</template>
<style scoped>
.mobile-task-search :deep(.skin-input) {
height: 3rem;
border-radius: 1rem;
background: color-mix(in oklch, var(--secondary) 80%, transparent);
font-size: 1rem;
}
</style>
Navigation
Settings stack
Use DomAppStack when a settings flow needs to slide through nested screens while staying full-width at every viewport. Screens can be slot-defined for small custom surfaces or loaded dynamically from larger modules.
Settings stack
Explore Account → General → Language & Region, or Account → Apps → Calendar → Notifications. Mobile slides the back button and title with each screen; the Desktop preset shows the full breadcrumb path. Visited screens keep their state and scroll position.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import {
DomAppListItem,
DomAppProfile,
DomAppShell,
DomAppStack,
DomAppStackScreen,
DomAvatar,
DomBadge,
DomButton,
DomEmailInput,
DomNativeSelect,
DomTextInput,
DomToggle,
} from '@getdom/studio';
const path = ref(['settings', 'account']);
const marketingEnabled = ref(false);
const displayName = ref('Alex Morgan');
const email = ref('alex@example.com');
const profileImage = 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&crop=faces&w=192&h=192&q=80';
const language = ref('en-GB');
const region = ref('GB');
const use24HourTime = ref(true);
const languageOptions = [
{ label: 'English (UK)', value: 'en-GB' },
{ label: 'English (US)', value: 'en-US' },
{ label: 'Français', value: 'fr-FR' },
];
const regionOptions = [
{ label: 'United Kingdom', value: 'GB' },
{ label: 'United States', value: 'US' },
{ label: 'France', value: 'FR' },
];
const previewOptions = [
{ label: 'Always', value: 'always' },
{ label: 'When unlocked', value: 'unlocked' },
{ label: 'Never', value: 'never' },
];
const applications = ref([
{ id: 'calendar', title: 'Calendar', symbol: '17', description: 'Events, invitations, and shared calendars.', storage: '24 MB', sync: true, alerts: true, sounds: true, badges: true, previews: 'unlocked' },
{ id: 'notes', title: 'Notes', symbol: 'N', description: 'Your notes, checklists, and shared notebooks.', storage: '86 MB', sync: true, alerts: false, sounds: false, badges: true, previews: 'unlocked' },
]);
const icons = {
account: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm7 8a7 7 0 0 0-14 0" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
bell: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M18 9a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M10 21h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
lock: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M7 11V8a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="1.8"/><rect x="5" y="11" width="14" height="10" rx="2" stroke="currentColor" stroke-width="1.8"/></svg>',
card: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M3 10h18M7 15h4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
general: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 7h16M4 17h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="9" cy="7" r="3" fill="var(--secondary)" stroke="currentColor" stroke-width="1.8"/><circle cx="15" cy="17" r="3" fill="var(--secondary)" stroke="currentColor" stroke-width="1.8"/></svg>',
apps: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="2" stroke="currentColor" stroke-width="1.8"/><rect x="14" y="3" width="7" height="7" rx="2" stroke="currentColor" stroke-width="1.8"/><rect x="3" y="14" width="7" height="7" rx="2" stroke="currentColor" stroke-width="1.8"/><rect x="14" y="14" width="7" height="7" rx="2" stroke="currentColor" stroke-width="1.8"/></svg>',
};
const screens = [
{
id: 'settings',
title: 'Settings',
children: ['account', 'notifications', 'security', 'billing'],
},
{
id: 'notifications',
title: 'Notifications',
description: 'Push, email, and quiet hours.',
meta: 'On',
icon: icons.bell,
parent: 'settings',
component: () => import('./settings/NotificationsScreen.vue'),
},
{
id: 'security',
title: 'Security',
description: 'Sign-in controls and trusted devices.',
meta: 'Strong',
icon: icons.lock,
parent: 'settings',
component: () => import('./settings/SecurityScreen.vue'),
},
{
id: 'billing',
title: 'Billing',
description: 'Plan, seats, and invoices.',
meta: 'Team',
icon: icons.card,
parent: 'settings',
component: () => import('./settings/BillingScreen.vue'),
},
];
</script>
<template>
<DomAppShell :content-scroll="false">
<DomAppStack
v-model:path="path"
root="settings"
layout="auto"
desktop-breakpoint="700px"
:screens="screens"
height="100%"
mobile-panel-class="bg-secondary/40"
desktop-content-class="bg-secondary/40"
>
<DomAppStackScreen
id="settings"
title="Settings"
:children="['account', 'notifications', 'security', 'billing']"
>
<template #default="{ push }">
<section class="space-y-4">
<div class="overflow-hidden rounded-[1.5rem] border border-border bg-canvas">
<DomAppListItem
:label="displayName"
description="Profile, preferences, and app settings."
chevron
@click="push('account')"
>
<template #icon>
<DomAvatar :name="displayName" :src="profileImage" />
</template>
</DomAppListItem>
</div>
<div class="overflow-hidden rounded-[1.5rem] border border-border bg-canvas">
<DomAppListItem
label="Notifications"
description="Push, email, and quiet hours."
meta="On"
chevron
@click="push('notifications')"
>
<template #icon>
<span class="grid size-5 place-items-center" v-html="icons.bell"></span>
</template>
</DomAppListItem>
<DomAppListItem
label="Security"
description="Passkeys, two-factor auth, and devices."
meta="Strong"
chevron
@click="push('security')"
>
<template #icon>
<span class="grid size-5 place-items-center" v-html="icons.lock"></span>
</template>
</DomAppListItem>
<DomAppListItem
label="Billing"
description="Plan, seats, receipts, and payment methods."
meta="Team"
chevron
@click="push('billing')"
>
<template #icon>
<span class="grid size-5 place-items-center" v-html="icons.card"></span>
</template>
</DomAppListItem>
</div>
</section>
</template>
</DomAppStackScreen>
<DomAppStackScreen
id="account"
title="Account"
description="Profile and workspace identity."
parent="settings"
:children="['profile', 'general', 'apps']"
:icon="icons.account"
>
<template #default="{ push }">
<section class="mx-auto max-w-2xl space-y-6">
<DomAppProfile :name="displayName" :email="email" :src="profileImage" @action="push('profile')" />
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem label="General" chevron @click="push('general')">
<template #icon><span class="size-5" v-html="icons.general"></span></template>
</DomAppListItem>
<DomAppListItem label="Apps" meta="2" chevron @click="push('apps')">
<template #icon><span class="size-5 text-primary" v-html="icons.apps"></span></template>
</DomAppListItem>
</div>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem label="Plan" meta="Studio Team" chevron @click="push('billing')">
<template #icon><span class="size-5 text-primary" v-html="icons.card"></span></template>
</DomAppListItem>
<DomAppListItem as="div" label="Marketing updates" description="Product news and early feature notes.">
<template #trailing>
<DomToggle v-model="marketingEnabled" label="Marketing updates" chrome="none" />
</template>
</DomAppListItem>
</div>
</section>
</template>
</DomAppStackScreen>
<DomAppStackScreen id="general" title="General" parent="account" :children="['about', 'language']" v-slot="{ push }">
<section class="mx-auto max-w-2xl space-y-4">
<p class="px-1 text-sm leading-6 text-muted-fg">Make the workspace feel at home.</p>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem label="About" chevron @click="push('about')" />
<DomAppListItem label="Language & Region" chevron @click="push('language')" />
</div>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem as="div" label="24-hour time">
<template #trailing><DomToggle v-model="use24HourTime" label="24-hour time" chrome="none" /></template>
</DomAppListItem>
</div>
</section>
</DomAppStackScreen>
<DomAppStackScreen id="about" title="About" parent="general">
<div class="mx-auto max-w-2xl overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem as="div" label="Name" meta="DOM Studio" />
<DomAppListItem as="div" label="Version" meta="1.0.0 (Preview)" />
<DomAppListItem as="div" label="Account" :meta="displayName" />
<DomAppListItem as="div" label="Plan" meta="Studio Team" />
</div>
</DomAppStackScreen>
<DomAppStackScreen id="language" title="Language & Region" parent="general">
<section class="mx-auto max-w-2xl space-y-4">
<div class="space-y-5 rounded-2xl border border-border bg-canvas p-4">
<DomNativeSelect v-model="language" label="Preferred language" :options="languageOptions" placeholder="" />
<DomNativeSelect v-model="region" label="Region" :options="regionOptions" placeholder="" />
</div>
<p class="px-4 text-xs leading-5 text-muted-fg">Language and region preferences are kept while you explore this example.</p>
</section>
</DomAppStackScreen>
<DomAppStackScreen id="apps" title="Apps" parent="account" :children="['calendar', 'notes']" v-slot="{ push }">
<section class="mx-auto max-w-2xl space-y-4">
<p class="px-1 text-sm leading-6 text-muted-fg">Choose an app to manage its notifications and preferences.</p>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem v-for="application in applications" :key="application.id" :label="application.title" chevron @click="push(application.id)">
<template #icon><span class="grid size-8 place-items-center rounded-lg bg-primary/10 text-sm font-semibold text-primary" aria-hidden="true">{{ application.symbol }}</span></template>
</DomAppListItem>
</div>
</section>
</DomAppStackScreen>
<template v-for="application in applications" :key="application.id">
<DomAppStackScreen :id="application.id" :title="application.title" parent="apps" :children="[`${application.id}-notifications`]" v-slot="{ push }">
<section class="mx-auto max-w-2xl space-y-6">
<div class="flex flex-col items-center px-4 pt-4 text-center">
<span class="grid size-16 place-items-center rounded-2xl bg-primary/10 text-2xl font-semibold text-primary" aria-hidden="true">{{ application.symbol }}</span>
<h2 class="mt-3 text-xl font-semibold">{{ application.title }}</h2>
<p class="mt-1 text-sm leading-6 text-muted-fg">{{ application.description }}</p>
</div>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem label="Notifications" :meta="application.alerts ? 'On' : 'Off'" chevron @click="push(`${application.id}-notifications`)">
<template #icon><span class="size-5 text-primary" v-html="icons.bell"></span></template>
</DomAppListItem>
<DomAppListItem as="div" label="Sync across devices">
<template #trailing><DomToggle v-model="application.sync" :label="`Sync ${application.title} across devices`" chrome="none" /></template>
</DomAppListItem>
</div>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem as="div" label="Storage" :meta="application.storage" />
</div>
</section>
</DomAppStackScreen>
<DomAppStackScreen :id="`${application.id}-notifications`" title="Notifications" :parent="application.id">
<section class="mx-auto max-w-2xl space-y-6">
<p class="px-1 text-sm leading-6 text-muted-fg">Choose how {{ application.title }} gets your attention.</p>
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem as="div" label="Allow notifications">
<template #trailing><DomToggle v-model="application.alerts" label="Allow notifications" chrome="none" /></template>
</DomAppListItem>
</div>
<template v-if="application.alerts">
<div class="overflow-hidden rounded-2xl border border-border bg-canvas">
<DomAppListItem as="div" label="Sounds">
<template #trailing><DomToggle v-model="application.sounds" label="Sounds" chrome="none" /></template>
</DomAppListItem>
<DomAppListItem as="div" label="Badges">
<template #trailing><DomToggle v-model="application.badges" label="Badges" chrome="none" /></template>
</DomAppListItem>
</div>
<div class="rounded-2xl border border-border bg-canvas p-4">
<DomNativeSelect v-model="application.previews" label="Show previews" :options="previewOptions" placeholder="" />
</div>
</template>
<p class="px-4 text-xs leading-5 text-muted-fg">These preferences apply to {{ application.title }} in this example.</p>
</section>
</DomAppStackScreen>
</template>
<DomAppStackScreen
id="profile"
title="Profile"
description="Name and contact details."
parent="account"
v-slot="{ back }"
>
<section class="space-y-5">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-xl font-semibold tracking-tight text-canvas-fg">Profile</h2>
<p class="mt-1 text-sm leading-6 text-muted-fg">Keep your visible workspace identity up to date.</p>
</div>
<DomBadge>Editable</DomBadge>
</div>
<div class="space-y-4 rounded-[1.5rem] border border-border bg-canvas p-4">
<DomTextInput v-model="displayName" label="Display name" />
<DomEmailInput v-model="email" label="Email address" />
</div>
<DomButton class="w-full" @click="back()">Save profile</DomButton>
</section>
</DomAppStackScreen>
</DomAppStack>
</DomAppShell>
</template>
Layout
Choose who owns the viewport
DomAppShell is the app wrapper. It defaults to 100dvh, reserves device safe areas once, pins the top and bottom slots, and scrolls its main region. It works without positioning styles on the page. Use a fixed CSS height for an embedded app, or height="100%" when its parent already has an explicit height.
DomAppStack can also stand alone. Each screen owns its scrolling, so when you nest it in a shell, turn off the shell’s content scrolling:
<DomAppShell :content-scroll="false">
<DomAppStack root="settings" :screens="screens" height="100%" />
</DomAppShell>Rows, top bars, bottom navigation, and safe-area wrappers work independently. For a row containing a toggle or input, use DomAppListItem as="div" to avoid nesting interactive controls inside a button.
DomActionSheet works as a viewport overlay by itself. Put it in the shell’s #overlay slot to contain it within that app. Long action lists scroll, and Cancel stays reachable.
For an edge-to-edge iOS webview, set width=device-width, initial-scale=1, viewport-fit=cover in the viewport meta tag. The components read the device safe-area insets. If native chrome already reserves those areas, avoid applying them a second time. WebKit’s safe-area guidance.
The phone frame simulates a cutout. Real keyboard resizing, system gestures, native plugin behaviour, and device safe areas still need testing in your target iOS and Android webviews.
Desktop and mobile
Action sheets work beyond phones
Document actions
A standalone sheet in a desktop workspace. Try the long list to see actions scroll while Cancel stays visible.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomActionSheet, DomButton } from '@getdom/studio';
const open = ref(false);
const longList = ref(false);
const result = ref('No action selected.');
const actions = computed(() => longList.value
? Array.from({ length: 18 }, (_, index) => ({ label: `Move to project ${index + 1}`, value: `project-${index + 1}` }))
: [
{ label: 'Copy document link', value: 'copy', description: 'Copy a link to the quarterly report' },
{ label: 'Duplicate document', value: 'duplicate' },
{ label: 'Download PDF', value: 'download', disabled: true, description: 'Available when the report is published' },
{ label: 'Archive document', value: 'archive', variant: 'danger' },
]);
/** Opens either the standard actions or the deliberately long scrolling list. */
function showActions(long = false) {
longList.value = long;
open.value = true;
}
/** Reports the demo selection; persistence and device APIs belong to the app. */
function selectAction(action) {
result.value = `Selected: ${action.label}. This preview does not change a document.`;
}
</script>
<template>
<section class="mx-auto w-full max-w-4xl space-y-6 p-5 sm:p-8">
<header class="flex flex-wrap items-center justify-between gap-4 border-b border-border pb-5">
<div>
<p class="text-xs font-medium text-muted-fg">Workspace / Reports</p>
<h2 class="mt-1 text-xl font-semibold tracking-tight">Quarterly product review</h2>
</div>
<DomButton variant="secondary" @click="showActions()">Document actions</DomButton>
</header>
<div class="space-y-4 rounded-xl border border-border bg-secondary/20 p-5">
<p class="text-sm font-semibold">Ready for the team review</p>
<p class="max-w-xl text-sm leading-6 text-muted-fg">Keep document commands together in a sheet that works with a mouse, keyboard, or touch. The same component fits a desktop workspace and a mobile screen.</p>
<DomButton variant="secondary" @click="showActions(true)">Try a long action list</DomButton>
</div>
<p role="status" class="text-sm text-muted-fg">{{ result }}</p>
<DomActionSheet v-model="open" :title="longList ? 'Move document' : 'Document actions'" description="Quarterly product review" :actions="actions" @select="selectAction" />
</section>
</template>
Components
App primitives for mobile
<DomAppShell>Full-screen app surface with safe-area-aware top and bottom slots.
<DomAppTopBar>Compact app header with leading and trailing action slots.
<DomAppBottomNav>Thumb-friendly primary navigation for app tabs.
<DomAppListItem>Touch-sized rows for settings, menus, inboxes, and task lists.
<DomAppProfile>An account header with a profile image, name, email, and an editable action.
<DomSafeArea>Safe-area padding for app chrome around cutouts and home indicators.
<DomAppStack>Settings-style nested navigation with slide transitions, breadcrumbs, lazy screens, and responsive panel styling.
<DomAppStackScreen>Slot-driven screen registration for custom stack content inside DomAppStack.
<DomTextInput>Use the shared text input with type="search" for mobile search fields.
<DomActionSheet>Bottom action sheet for app choices and destructive commands.
<DomBottomSheet>Expandable content sheet with snap heights, a scrolling body, and pinned controls.
<DomSwipeActions>Swipe row actions, optional full-swipe shortcuts, and accessible button controls.