·10 min read

Developing of Common Layout Components

This time, we will develop common layout components such as Header, Footer, and Page. Along with this, we'll create and integrate a Language Switcher, Theme Switcher, and shared components like Button and Link into the layout.


app.vue

<script setup lang="ts">
const head = useLocaleHead()
</script>

<template>
  <Html
    :lang="head.htmlAttrs?.lang"
    :dir="head.htmlAttrs?.dir"
  >
    <Head>
      <template
        v-for="link in head.link"
        :key="link.id"
      >
        <Link
          :id="link.id"
          :rel="link.rel"
          :href="link.href"
          :hreflang="link.hreflang"
        />
      </template>
      <template
        v-for="meta in head.meta"
        :key="meta.id"
      >
        <Meta
          :id="meta.id"
          :property="meta.property"
          :content="meta.content"
        />
      </template>
    </Head>
    <Body>
      <nuxt-loading-indicator />
      <the-header />
      <nuxt-page />
      <the-footer />
    </Body>
  </Html>
</template>
  • Line 34: Displays a loading indicator at the top during page navigation, providing users with a visual cue about the current progress.
  • Lines 35·37: Header and Footer are essential components for structuring the site layout, focusing more on clarity of page structure rather than reusability. To avoid errors, create the component files first before adding code.

Button Component

Before building the header, let’s create a button component to be used across the entire project.

<script setup lang="ts">
  import { twJoin, twMerge } from 'tailwind-merge'
  import ButtonStyles from '~/constant/ButtonStyle'
  import type { ButtonProps } from '~/types/Button'

  const props = withDefaults(defineProps<ButtonProps>(), {
    variant: 'solid',
    color: 'primary',
  })

  const resolveVariant = computed(() => {
    return twJoin(
        ButtonStyles[props.variant].base,
        ButtonStyles[props.variant][unref(props.color)],
        props.square ? 'p-1.5' : 'px-2.5 py-1.5 ',
    )
  })
</script>

<template>
  <button :class="twMerge(resolveVariant, props.class)">
    <slot />
  </button>
</template>

When developing buttons, you often need to create multiple designs with subtle differences. To prevent the button component from becoming excessively bloated, I chose to manage only basic style presets, allowing parent components to override CSS classes as needed.

The appearance of the link component is identical to the button, but there are key differences due to its nature as a link:

  1. If the link is active, it is forced to use the primary color.
  2. Using the active class property of nuxt-link caused conflicts with Tailwind CSS classes. This has been addressed.
  3. It also supports an inactive class.
<script setup lang="ts">
import { twJoin, twMerge } from 'tailwind-merge'
import type { RouterLinkProps } from '#vue-router'
import { NuxtLink, NuxtLinkLocale } from '#components'
import type { ButtonProps } from '~/types/Button'
import ButtonStyles from '~/constant/ButtonStyle'

defineOptions({
  inheritAttrs: false,
})

interface Props extends Partial<RouterLinkProps>, ButtonProps {
  inactiveClass?: string
  isLocalePath?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'ghost',
  color: 'gray',
  isLocalePath: true,
})

const resolveVariant = (variant: ButtonProps['variant'], color: ButtonProps['color'], isActive: boolean) => {
  const _variant = variant!
  const _color = isActive ? 'primary' : unref(color)!
  return twJoin(
    ButtonStyles[_variant].base,
    ButtonStyles[_variant][_color],
    props.square ? 'p-1.5' : 'px-2.5 py-1.5',
  )
}

const { isLocalePath: _isLocalePath, viewTransition: _viewTransition, variant: _variant, square: _square, color: _color, ...bindingProps } = props
</script>

<template>
  <component
    :is="isLocalePath? NuxtLinkLocale : NuxtLink"
    v-bind="bindingProps"
    v-slot="{ href, navigate, isActive, isExternal }"
    custom
  >
    <a
      v-bind="$attrs"
      :href="href"
      :class="twMerge(resolveVariant(variant, color, isActive), props.class, isActive ? activeClass : inactiveClass)"
      @click="(e) => !isExternal && navigate(e)"
    >
      <slot />
    </a>
  </component>
</template>

Header Component

The header is composed of the following four sections:

  • Logo Area: Displays the logo and a home link.
  • GNB: Provides links to main pages.
  • Utility Bar: Includes buttons for language and theme switching.
  • Mobile Panel: Displays the GNB and utility bar on mobile screens.
<script setup lang="ts">
  const links = [
    { label: 'Home', to: '/' },
    { label: 'Blog', to: '/blog' },
    { label: 'Archives', to: '/archives' },
  ]

  const isCollapsed = ref(false)

  useHead({
    bodyAttrs: {
      class: [() => isCollapsed.value ? 'overflow-hidden' : ''],
    },
  })

  const route = useRoute()
  watch(() => route.path, () => {
    isCollapsed.value = false
  })
</script>

<template>
  <header
      class="bg-white/75 dark:bg-slate-900/75 backdrop-blur relative top-0 z-50 [--header-height:7rem]"
      :class="isCollapsed ? 'bg-white dark:bg-black ' : 'bg-white/75 dark:bg-black/75'"
  >
    <div class="pl-6 pr-4 lg:px-8 mx-auto max-w-7xl h-[--header-height] flex items-center justify-between gap-3">
      <!-- logo section -->
      <div class="lg:flex-1 flex items-center gap-1.5">
        <nuxt-link-locale
            to="/"
            class="font-bold leading-none text-2xl dark:text-slate-100 flex items-center gap-3"
            aria-label="Go to home"
        >
          <icon
              name="besfir:logo"
              size="32"
          />

          Besfir
        </nuxt-link-locale>
      </div>

      <!-- gnb section -->
      <div class="items-center gap-x-3 hidden lg:flex">
        <div
            v-for="{ label, to } in links"
            :key="label"
            class="relative"
        >
          <app-link
              :to="to"
              class="text-base"
          >
            {{ label }}
          </app-link>
        </div>
      </div>

      <!-- utility bar section -->
      <div class="flex items-center justify-end lg:flex-1 gap-1.5">
        <the-lang-switcher class="hidden lg:flex" />
        <the-theme-switcher class=" hidden lg:flex" />
        <app-button
            class="lg:hidden"
            color="gray"
            variant="ghost"
            square
            :aria-label="isCollapsed ? 'Close aside' : 'Open aside'"
            @click="isCollapsed = !isCollapsed"
        >
          <icon
              v-if="isCollapsed"
              name="ph:x-bold"
          />
          <icon
              v-else
              name="ph:list-bold"
          />
        </app-button>
      </div>

      <!-- mobile panel section -->
      <div
          v-show="isCollapsed"
          class="fixed inset-x-0 top-[--header-height] h-[calc(100vh-var(--header-height))] bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-700 px-8 lg:hidden"
      >
        <div class="pt-6 pb-24 max-w-[288px] mx-auto">
          <app-link
              v-for="{ label, to } in links"
              :key="label"
              :to="to"
              class="py-3 flex border-b dark:border-slate-700 rounded-none"
          >
            {{ label }}
          </app-link>

          <div class="flex items-center justify-center mt-4">
            <the-lang-switcher />
          </div>

          <div class="text-xs py-2 pl-4 pr-3 bg-slate-100 dark:bg-slate-800 rounded-lg flex items-center justify-between mt-6">
            Color Mode <the-theme-switcher class="ml-auto" />
          </div>

          <app-sns-links class="mt-4" />
        </div>
      </div>
    </div>
  </header>
</template>

You can use any logo image you have prepared. I used a sample from the hand drawn illustration available on the Figma Community.

The Header component (line 106) and the Footer component, which we will create later, are designed to display your SNS links. To make them reusable, we’ll create a component specifically for SNS links.

<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { twMerge } from 'tailwind-merge'

interface Props {
  class?: HTMLAttributes['class']
}
const props = defineProps<Props>()

const sns = [
  { icon: 'ph:instagram-logo', to: 'https://www.instagram.com/', label: 'go to instagram' },
  { icon: 'ph:github-logo', to: 'https://github.com/', label: 'go to github' },
  { icon: 'ph:linkedin-logo', to: 'https://www.linkedin.com/', label: 'go to linkedin' },
]
</script>

<template>
  <div :class="twMerge('flex items-center justify-center gap-x-1.5', props.class)">
    <app-link
      v-for="{ to, icon, label } in sns"
      :key="icon"
      :to="to"
      square
      rel="noopener noreferrer"
      target="_blank"
      :aria-label="label"
    >
      <icon
        :name="icon"
        aria-hidden="true"
      />
    </app-link>
  </div>
</template>

Language Switch

We will use lodash, so you need to install the Nuxt lodash module.

bun i -D nuxt-lodash

Let’s create a language switch that allows users to toggle the current display language of the website. Since each language page may have a different fragment(hash) value, routing errors can occur during language switching. To prevent this, we use the withoutFragment() function to remove fragments from the route path.

<script setup lang="ts">
import { withoutFragment } from 'ufo'

const { locale, locales } = useI18n()
const localeItems = computed(() => {
  const [currentLocale, availableLocales] = usePartition(locales.value.map(({ code, name }) => ({ code, name })), ({ code }) => code === locale.value)
  return { currentLocale: currentLocale[0], availableLocales }
})
const switchLocalePath = useSwitchLocalePath()
</script>

<template>
  <div class="relative group">
    <app-button
      color="gray"
      variant="ghost"
      square
      aria-label="language switcher"
    >
      <icon name="ph:translate-duotone"/>
      <span class=" lg:hidden">{{ localeItems.currentLocale.name }}</span>
      <icon
        name="ph:caret-up-down"
        class="lg:hidden"
      />
    </app-button>

    <div class="hidden flex-col group-hover:flex absolute top-full right-0 shadow-lg p-3 border border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900">
      <span class="px-3 py-1.5 text-sm font-bold hidden lg:block select-none">{{ localeItems.currentLocale.name }}</span>
      <app-link
        v-for="{ code, name } in localeItems.availableLocales"
        :key="code"
        :to="withoutFragment(switchLocalePath(code))"
        :is-locale-path="false"
        class="px-3"
      >
        <span class="truncate text-sm">{{ name }}</span>
      </app-link>
    </div>
  </div>
</template>

Theme Switch

Let’s create a theme switch that toggles between light and dark modes. While I did not implement System preference detection to match the theme with the system settings, you can refer to the Nuxt Color Mode module documentation if needed.

<template>
  <app-button
    :aria-label="`Switch to ${isDark ? 'light' : 'dark'} mode`"
    variant="ghost"
    :color="isDark ? 'primary' : 'gray'"
    square
    v-bind="$attrs"
    @click="isDark = !isDark"
  >
    <icon :name="isDark ? 'ph:moon-stars-duotone' : 'ph:sun-duotone'" />
  </app-button>
</template>

<script setup lang="ts">
const colorMode = useColorMode()

const isDark = computed({
  get() {
    return colorMode.value === 'dark'
  },
  set() {
    colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
  },
})
</script>

The footer provides information at the bottom of the site, with the following main sections:

  • Section Divider
  • Copyright & Credits
  • Donation Links
  • SNS Links
<script setup lang="ts">
const links = [
  {
    label: 'Ko-fi',
    to: 'https://ko-fi.com/besfir/?hidefeed=true&widget=true&embed=true',
  }, {
    label: 'Buy me a coffee',
    to: 'https://buymeacoffee.com/5kni3hu',
  }, {
    label: '투네이션',
    to: 'https://toon.at/donate/besfir',
  },
]
</script>

<template>
  <div>
    <!-- divider section -->
    <div class="w-full h-px bg-slate-200 dark:bg-slate-800 flex items-center justify-center">
      <div class="bg-white dark:bg-slate-900 px-4">
        <icon
          name="ph:hand-heart-duotone"
          aria-hidden="true"
        />
      </div>
    </div>

    <footer class="relative text-slate-500 dark:text-slate-400">
      <div class="mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl py-8 lg:py-4 lg:flex lg:items-center lg:justify-between lg:gap-x-3">
        <!-- sns links section -->
        <app-sns-links class="lg:flex-1 lg:justify-end lg:order-3" />
        <!-- donation links section -->
        <div class="mt-3 lg:mt-0 lg:order-2 flex items-center justify-center">
          <div class="flex flex-col md:flex-row items-center justify-center gap-0.5 lg:gap-1">
            <app-link
              v-for="{ label, to } in links"
              :key="label"
              class="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 relative"
              :to="to"
              rel="noopener noreferrer"
              target="_blank"
            >
              {{ label }}
            </app-link>
          </div>
        </div>
        <!-- copyright & credits section -->
        <div class="flex flex-col items-center justify-center lg:items-start lg:flex-1 gap-x-1.5 mt-3 lg:mt-0 lg:order-1 text-xs gap-y-1">
          <span>© 2024-{{ new Date().getFullYear() }} besfir blog</span>
          <span>Powered by Nuxt.js, Cloudflare Pages</span>
        </div>
      </div>
    </footer>
  </div>
</template>

Page Components

We’ll create components that serve as the body for each page component. Similar to the previously built button and link components, these are designed to allow parent components to customize styles.

<script setup lang="ts">
import { twJoin, twMerge } from 'tailwind-merge'
import type { HTMLAttributes } from 'vue'

defineOptions({
  inheritAttrs: false,
})
interface Props {
  class?: HTMLAttributes['class']
  innerClass?: HTMLAttributes['class']
  isProse?: boolean
}
const props = defineProps<Props>()

const resolveClass = computed(() => {
  return twMerge(
    twJoin('px-6 md:px-12 pb-10 min-h-[calc(100vh-var(--header-height))]', props.isProse && 'prose prose-slate dark:prose-invert max-w-none md:prose-lg'),
    props.class,
  )
})
</script>

<template>
  <div :class="resolveClass">
    <div :class="twMerge('mx-auto max-w-screen-lg', innerClass)">
      <slot />
    </div>
  </div>
</template>

Project Structure So Far

Refer to this section for the file paths of the source code.

.
├── README.md
├── app.vue
├── assets
│   ├── css
│   │   └── main.pcss
│   └── icons
│       └── logo.svg
├── bun.lockb
├── components
│   ├── App
│   │   ├── Button.vue
│   │   ├── Link.vue
│   │   └── SnsLinks.vue
│   └── The
│       ├── Footer.vue
│       ├── Header.vue
│       ├── LangSwitcher.vue
│       ├── Page.vue
│       └── ThemeSwitcher.vue
├── constant
│   └── ButtonStyle.ts
├── eslint.config.mjs
├── i18n
│   ├── i18n.config.ts
│   └── locales
│       ├── en.yaml
│       └── ko.yaml
├── nuxt.config.ts
├── package.json
├── pages
│   ├── archives.vue
│   ├── blog.vue
│   └── index.vue
├── public
│   ├── favicon.ico
│   └── robots.txt
├── server
│   └── tsconfig.json
├── tailwind.config.ts
├── tsconfig.json
└── types
    └── Button.ts