Vue templateFree

Settings Page

Account settings template for Vue 3 and Tailwind CSS with section nav, profile and account forms, notification toggles, billing and a floating save bar.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this template

The settings screen most apps need, done properly: a section nav (profile, account, notifications, billing, danger zone) that sits in a sticky sidebar on desktop and becomes a scrollable pill row on mobile, with a form for each area.

Edits are tracked against the last saved snapshot. As soon as anything differs, a save bar slides up showing how many fields changed, with Discard and Save (also Cmd/Ctrl+S), and the browser warns before you leave with unsaved work. Changes survive switching sections because all state lives in one reactive object in App.vue.

Details are covered: accessible switches with role="switch", an email/push notification matrix with per-column all/none, radio cards for the digest, a local avatar preview, a bio character counter, and a native <dialog> delete confirmation that asks you to type your username. Emerald is the single accent colour and shared input styles live in src/ui.ts.

  • Sections: profile, account and security, notifications, billing, danger zone
  • Floating save bar that appears only with unsaved changes (Cmd/Ctrl+S to save)
  • Accessible toggles, radio cards and a notification matrix
  • Billing plan, usage meters, payment method and invoices
  • Type-to-confirm delete dialog built on native <dialog>

Code

21 files in the repo
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { clone, initialSettings, type SectionId } from './settings'
import AccountSection from './components/AccountSection.vue'
import BillingSection from './components/BillingSection.vue'
import DangerZone from './components/DangerZone.vue'
import NotificationsSection from './components/NotificationsSection.vue'
import ProfileSection from './components/ProfileSection.vue'
import SaveBar from './components/SaveBar.vue'
import SectionNav, { type NavSection } from './components/SectionNav.vue'

const sections: NavSection[] = [
  { id: 'profile', label: 'Profile', icon: 'M15.5 16.5v-.75a3.25 3.25 0 0 0-3.25-3.25h-4.5a3.25 3.25 0 0 0-3.25 3.25v.75M10 9.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z' },
  { id: 'account', label: 'Account', icon: 'M5 9V6.5a5 5 0 0 1 10 0V9M4.5 9h11a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1ZM10 12.25v1.5' },
  { id: 'notifications', label: 'Notifications', icon: 'M15 8a5 5 0 0 0-10 0c0 5.5-2 7-2 7h14s-2-1.5-2-7ZM11.4 17.5a1.6 1.6 0 0 1-2.8 0' },
  { id: 'billing', label: 'Billing', icon: 'M3 6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6ZM3 8.5h14M6 12h2.5' },
  { id: 'danger', label: 'Danger zone', icon: 'M10 7.5v3.5M10 14h.01M8.6 3.4 2.4 14a1.6 1.6 0 0 0 1.4 2.4h12.4a1.6 1.6 0 0 0 1.4-2.4L11.4 3.4a1.6 1.6 0 0 0-2.8 0Z' },
]
const current = ref<SectionId>('profile')

// `saved` is what the server has; `form` is what the user is editing.
const saved = ref(clone(initialSettings))
const form = reactive(clone(initialSettings))

function leaves(value: unknown, prefix = ''): Record<string, unknown> {
  if (value === null || typeof value !== 'object') return { [prefix]: value }
  return Object.entries(value).reduce<Record<string, unknown>>((acc, [k, v]) => ({ ...acc, ...leaves(v, prefix ? `${prefix}.${k}` : k) }), {})
}
const changedCount = computed(() => {
  const a = leaves(form), b = leaves(saved.value)
  return Object.keys(a).filter(k => a[k] !== b[k]).length
})
const dirty = computed(() => changedCount.value > 0)

const saving = ref(false)
const toast = ref('')
let toastTimer: ReturnType<typeof setTimeout> | undefined
function notify(message: string) {
  toast.value = message
  clearTimeout(toastTimer)
  toastTimer = setTimeout(() => { toast.value = '' }, 3000)
}

async function save() {
  if (!dirty.value || saving.value) return
  saving.value = true
  await new Promise(resolve => setTimeout(resolve, 700)) // replace with your API call
  saved.value = clone(form)
  saving.value = false
  notify('Settings saved')
}
function discard() {
  Object.assign(form, clone(saved.value))
}

function exportData() {
  const blob = new Blob([JSON.stringify(saved.value, null, 2)], { type: 'application/json' })
  const link = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: 'quillpad-settings.json' })
  link.click()
  URL.revokeObjectURL(link.href)
}

// Cmd/Ctrl+S saves, and the browser warns before leaving with unsaved edits.
function onKey(event: KeyboardEvent) {
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
    event.preventDefault()
    save()
  }
}
function onBeforeUnload(event: BeforeUnloadEvent) {
  if (dirty.value) event.preventDefault()
}
onMounted(() => {
  window.addEventListener('keydown', onKey)
  window.addEventListener('beforeunload', onBeforeUnload)
})
onBeforeUnmount(() => {
  window.removeEventListener('keydown', onKey)
  window.removeEventListener('beforeunload', onBeforeUnload)
})

function select(id: SectionId) {
  current.value = id
  document.getElementById('settings-panel')?.focus({ preventScroll: true })
  window.scrollTo({ top: 0, behavior: 'smooth' })
}
const currentLabel = computed(() => sections.find(s => s.id === current.value)?.label ?? '')
</script>

<template>
  <div class="min-h-screen bg-gray-50 text-gray-900 antialiased dark:bg-gray-950 dark:text-gray-100">
    <header class="border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
      <div class="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 sm:px-6">
        <span class="grid size-8 place-items-center rounded-lg bg-emerald-600 text-white" aria-hidden="true">
          <svg viewBox="0 0 20 20" class="size-4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 3.5 16.5 6.5 8 15l-4 1 1-4 8.5-8.5Z" /></svg>
        </span>
        <span class="text-[15px] font-semibold tracking-tight">Quillpad</span>
        <a href="#" class="ml-auto rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white" @click.prevent="notify('This would take you back to your notes.')">
          ← Back to notes
        </a>
      </div>
    </header>

    <div class="mx-auto max-w-6xl px-4 pt-8 pb-32 sm:px-6 lg:pt-10">
      <h1 class="text-2xl font-semibold tracking-tight text-gray-900 sm:text-3xl dark:text-white">Settings</h1>
      <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Manage your profile, sign-in, notifications and subscription.</p>

      <div class="mt-6 lg:mt-8 lg:grid lg:grid-cols-[13rem_1fr] lg:gap-10">
        <aside class="lg:sticky lg:top-8 lg:self-start">
          <SectionNav :sections="sections" :current="current" @select="select" />
        </aside>

        <main id="settings-panel" tabindex="-1" class="mt-6 outline-none lg:mt-0" :aria-label="`${currentLabel} settings`">
          <ProfileSection v-if="current === 'profile'" v-model="form.profile" />
          <AccountSection v-else-if="current === 'account'" v-model="form.account" @change-password="notify('A password reset link is on its way to your inbox.')" />
          <NotificationsSection v-else-if="current === 'notifications'" v-model="form.notifications" />
          <BillingSection v-else-if="current === 'billing'" v-model="form.billing" @notify="notify" />
          <DangerZone
            v-else
            :username="saved.profile.username"
            @export-data="exportData"
            @deactivate="notify('Account deactivated. (Demo, nothing changed.)')"
            @delete-account="notify('Account scheduled for deletion. (Demo, nothing changed.)')"
          />
        </main>
      </div>
    </div>

    <SaveBar :visible="dirty" :saving="saving" :changes="changedCount" @save="save" @discard="discard" />

    <Transition enter-from-class="-translate-y-2 opacity-0" leave-to-class="opacity-0" enter-active-class="transition duration-200" leave-active-class="transition duration-150">
      <div v-if="toast" role="status" class="fixed top-4 left-1/2 z-50 flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 items-center gap-3 rounded-xl bg-white px-4 py-3 text-sm font-medium text-gray-900 shadow-lg ring-1 ring-gray-900/10 dark:bg-gray-800 dark:text-white dark:ring-white/10">
        <svg viewBox="0 0 20 20" class="size-5 shrink-0 text-emerald-600 dark:text-emerald-400" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.7-9.3a1 1 0 0 0-1.4-1.4L9 10.6 7.7 9.3a1 1 0 0 0-1.4 1.4l2 2a1 1 0 0 0 1.4 0l4-4Z" clip-rule="evenodd" /></svg>
        {{ toast }}
      </div>
    </Transition>
  </div>
</template>
All dashboards →
Preview of Admin Dashboard$29

Template · Dashboards

Admin Dashboard

A complete Vue 3 admin dashboard template with a collapsible sidebar, hand-drawn SVG charts, a sortable customers table and settings. Tailwind CSS, dark mode.

by Vueplay
Preview of Analytics DashboardFree

Template · Dashboards

Analytics Dashboard

Web analytics dashboard template for Vue 3 and Tailwind CSS: SVG area chart, metric tabs, top pages, sources, a devices donut and a date range picker.

by Vueplay
Preview of Auth PagesFree

Template · Auth pages

Auth Pages

Sign in, sign up and forgot-password screens in Vue 3 with a split brand panel, inline validation and a password visibility toggle. Tailwind CSS, dark mode.

by Vueplay
Preview of 404 Not Found PageFree

Template · Error pages

404 Not Found Page

Friendly Vue 3 404 page with a drawn compass illustration, live search suggestions and helpful links, plus a compact 500 error variant. Tailwind CSS.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings