Portfolio block

In this lesson, we will create the first block in our project, which is the Portfolio block.


Goal

We want to create a page block that displays portfolio items in a three-column grid. Each item should be clickable, opening a fullscreen overlay with a slider to display the corresponding image. Here's a screenshot of the desired design:

The design of the Portfolio block

Remember, you can always refer to the Figma design by opening and cloning it to your drafts.

Uploading images

Let's start by uploading all the images from the design. I've sorted them into four directories for better organization.

Creating the block

Now, let's create the Portfolio.vue file in the blocks directory of our project:

# blocks/Portfolio.vue

<template>
  <Container class="space-y-6 ph:space-y-5">
    <h2
      class="relative before:absolute before:right-full before:top-1/2 before:mr-3 before:h-2 before:w-2 before:-translate-y-1/2 before:rounded-full before:bg-heading ph:before:mr-2.5 ph:before:h-1 ph:before:w-1"
    >
      {{ title }}
    </h2>

    <div class="grid grid-cols-3 gap-5 tp:grid-cols-2 ph:grid-cols-1">
      <button
        v-for="{ image, caption } of gallery"
        aria-label="Open image"
        class="group relative overflow-hidden rounded-md"
      >
        <!-- @see https://pruvious.com/docs/components#pruviouspicture -->
        <PruviousPicture :image="image" />

        <span
          class="absolute inset-0 flex flex-col gap-4 bg-heading/70 p-8 text-white opacity-0 transition duration-300 group-hocus:opacity-100 ph:p-6"
        >
          <span
            v-if="caption"
            class="-translate-y-3 text-left font-heading font-medium transition duration-300 group-hocus:translate-y-0"
          >
            {{ caption }}
          </span>

          <!-- TODO: Replace with SVG icon -->
          <span class="ml-auto mt-auto h-6 w-6 translate-y-3 transition duration-300 group-hocus:translate-y-0">
            🔍
          </span>
        </span>
      </button>
    </div>
  </Container>
</template>

<script lang="ts" setup>
import { defineBlock, imageSubfield, repeaterField, textField, textSubfield } from '#pruvious'

/**
 * @see https://pruvious.com/docs/blocks#defineblock
 */
defineBlock({
  icon: 'Photo',
  description: 'A portfolio block with a gallery of images that can be clicked to open a full-screen view.',
})

defineProps({
  /**
   * @see https://pruvious.com/docs/fields/text
   */
  title: textField({
    description: 'The title of the portfolio block',
  }),

  /**
   * @see https://pruvious.com/docs/fields/repeater
   */
  gallery: repeaterField({
    description: 'The gallery of images',
    subfields: {
      /**
       * @see https://pruvious.com/docs/fields/image
       */
      image: imageSubfield({
        description: 'The image to display',
        required: true,
        minWidth: 1024,
        minHeight: 1024,
        sources: [
          { format: 'webp', width: 1920, height: 1920, resize: 'inside', withoutEnlargement: true },
          { format: 'jpeg', width: 1920, height: 1920, resize: 'inside', withoutEnlargement: true },
        ],
      }),

      /**
       * @see https://pruvious.com/docs/fields/text
       */
      caption: textSubfield({
        description: 'An optional caption to display',
      }),
    },
  }),
})
</script>

If you have copied the block code into your project, you may notice that the styles are missing. This is because Tailwind is not watching the blocks directory. To resolve this, you need to manually include it in the tailwind.config.js file like this:

# tailwind.config.js

import defaultTheme from 'tailwindcss/defaultTheme'

/** @type {import('tailwindcss').Config} */
export default {
  content: ['blocks/**/*.vue'],
  ....
}

I've also included the colors used in the design, along with the modified line height for the sm font size. Additionally, there is a custom plugin that adds the hocus and group-hocus variants to provide better control over hover and focus states. Here is the complete tailwind.config.js file:

# tailwind.config.js

import defaultTheme from 'tailwindcss/defaultTheme'
import plugin from 'tailwindcss/plugin'

/** @type {import('tailwindcss').Config} */
export default {
  content: ['blocks/**/*.vue'],
  theme: {
    extend: {
      colors: {
        heading: '#010203',
        copy: '#234345',
        accent: '#268baa',
        border: '#e2e4e8',
        dim: '#59797b',
      },
      fontFamily: {
        sans: ['Lato', ...defaultTheme.fontFamily.sans],
        heading: ['Poppins', ...defaultTheme.fontFamily.mono],
      },
      fontSize: {
        sm: ['0.875rem', { lineHeight: '1rem' }],
      },
      screens: {
        lp: { max: '1440px' },
        tl: { max: '1199px' },
        tp: { max: '1023px' },
        ph: { max: '767px' },
      },
      spacing: {
        23: '5.75rem',
      },
    },
  },
  plugins: [
    plugin(function ({ addVariant }) {
      addVariant('hocus', ['&:hover', '&:focus'])
      addVariant('group-hocus', ['.group:hover &', '.group:focus &'])
    }),
  ],
}

I have also included the basic styles for the copy text and headings in tailwind.css:

# assets/css/tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  @apply text-copy;
}

h1,
h2,
h3 {
  @apply font-heading font-medium text-heading;
}

h1 {
  @apply text-[2rem] ph:text-[1.5rem];
}

h2 {
  @apply text-[1.5rem] ph:text-[1.25rem];
}

h3 {
  @apply text-[1.25rem] ph:text-[1rem];
}

This is what our block looks like now:

Adding icons

To fix the missing icon in the Portfolio block and to incorporate the left and right arrows and close icons for the slider, we will import all the necessary icons from the design before proceeding. We can achieve this by utilizing the icons directory in Pruvious. By creating components with SVG code in this directory, the icons will be automatically included in the icon field, which will be used later in this tutorial.

Here is the search icon that needs to be replaced in our Portfolio.vue file:

# icons/Search.vue

<template>
  <svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
    <g clip-path="url(#clip0_33_103)">
      <path
        d="M3 10C3 10.9193 3.18106 11.8295 3.53284 12.6788C3.88463 13.5281 4.40024 14.2997 5.05025 14.9497C5.70026 15.5998 6.47194 16.1154 7.32122 16.4672C8.1705 16.8189 9.08075 17 10 17C10.9193 17 11.8295 16.8189 12.6788 16.4672C13.5281 16.1154 14.2997 15.5998 14.9497 14.9497C15.5998 14.2997 16.1154 13.5281 16.4672 12.6788C16.8189 11.8295 17 10.9193 17 10C17 9.08075 16.8189 8.1705 16.4672 7.32122C16.1154 6.47194 15.5998 5.70026 14.9497 5.05025C14.2997 4.40024 13.5281 3.88463 12.6788 3.53284C11.8295 3.18106 10.9193 3 10 3C9.08075 3 8.1705 3.18106 7.32122 3.53284C6.47194 3.88463 5.70026 4.40024 5.05025 5.05025C4.40024 5.70026 3.88463 6.47194 3.53284 7.32122C3.18106 8.1705 3 9.08075 3 10Z"
        stroke="currentColor"
        stroke-linecap="round"
        stroke-linejoin="round"
        stroke-width="1.5"
      />
      <path d="M21 21L15 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
    </g>
    <defs>
      <clipPath id="clip0_33_103">
        <rect fill="currentColor" height="24" width="24" />
      </clipPath>
    </defs>
  </svg>
</template>

You can simply copy the icons as SVGs from Figma and paste them into corresponding components. I often replace the colors in the SVG with currentColor to easily apply CSS styling.

Now we can add the search icon to our Portfolio.vue block:

# blocks/Portfolio.vue

<template>
  ...
  <IconSearch class="group-hocus:translate-y-0 ml-auto mt-auto h-6 w-6 translate-y-3 transition duration-300" />
  ...
</template>

...

Here is a list of all the icons I have added in the icons directory:

  • BrandDiscord.vue

  • BrandGithub.vue

  • BrandX.vue

  • ChevronLeft.vue

  • ChevronRight.vue

  • Moon.vue

  • Search.vue

  • Sun.vue

  • X.vue

Creating the slider

The next step is to create a fullscreen slider that appears when an item is clicked. We'll use Swiper to handle most of the slider functionality for us. To ensure efficiency, we will create a separate component that loads lazily from the Portfolio block. This way, the Swiper scripts and assets will only be loaded when needed. Our component looks like this:

# components/Slider.vue

<template>
  <div ref="rootEl" class="fixed inset-0 z-20 bg-heading/80" :class="{ 'opacity-0 ': closing }">
    <button
      @click="close()"
      aria-label="Close"
      class="fixed right-6 top-6 z-10 flex h-8 w-8 text-white transition hocus:text-accent ph:right-4 ph:top-4"
    >
      <IconX class="m-auto h-6 w-6" />
    </button>

    <!-- @see https://swiperjs.com/get-started -->
    <div ref="containerEl" class="swiper-container h-screen">
      <div class="swiper-wrapper">
        <div
          v-for="({ image, caption }, i) of slides"
          class="swiper-slide !flex flex-col items-center justify-center gap-6 px-20 py-8 ph:px-6 ph:pt-16"
        >
          <div class="overflow-hidden">
            <PruviousPicture :image="image">
              <PruviousImage
                :image="image"
                @load="i === active && resizeNavigation()"
                class="h-auto max-h-full w-auto max-w-full rounded-md"
              />
            </PruviousPicture>
          </div>

          <p v-if="caption" class="text-center text-white">{{ caption }}</p>
        </div>
      </div>

      <div
        class="pointer-events-none fixed left-1/2 top-1/2 z-10 h-8 -translate-x-1/2 -translate-y-1/2 transition-all ph:hidden"
        :style="{ width: `${navigationWidth}px` }"
      >
        <button
          aria-label="Previous slide"
          ref="prevSlideEl"
          class="pointer-events-auto absolute right-full mr-6 flex h-8 w-8 text-white transition hocus:text-accent"
        >
          <IconChevronLeft class="m-auto h-6 w-6" />
        </button>

        <button
          aria-label="Next slide"
          ref="nextSlideEl"
          class="pointer-events-auto absolute left-full ml-6 flex h-8 w-8 text-white transition hocus:text-accent"
        >
          <IconChevronRight class="m-auto h-6 w-6" />
        </button>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { type Image } from '#pruvious'
import { useEventListener } from '@vueuse/core'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import Swiper from 'swiper'
import 'swiper/css'
import 'swiper/css/navigation'
import { Keyboard, Navigation, Pagination } from 'swiper/modules'

const props = defineProps({
  slides: {
    type: Array as PropType<{ image: Image | null; caption: string }[]>,
    required: true,
  },
  active: {
    type: Number,
    default: 0,
  },
})

const emit = defineEmits(['close'])

const rootEl = ref<HTMLElement>()
const containerEl = ref<HTMLElement>()
const prevSlideEl = ref<HTMLElement>()
const nextSlideEl = ref<HTMLElement>()
const navigationWidth = ref(0)
const focusTrap = useFocusTrap(rootEl, { escapeDeactivates: false, returnFocusOnDeactivate: false })
const closing = ref(false)

let swiper: Swiper | undefined

onMounted(() => {
  // @see https://swiperjs.com/get-started
  swiper = new Swiper(containerEl.value!, {
    modules: [Keyboard, Navigation, Pagination],
    initialSlide: props.active,
    keyboard: {
      enabled: true,
    },
    navigation: {
      prevEl: prevSlideEl.value!,
      nextEl: nextSlideEl.value!,
    },
    on: {
      resize: resizeNavigation,
      slideChange: resizeNavigation,
    },
  })

  // @see https://vueuse.org/integrations/useFocusTrap/
  nextTick(focusTrap.activate)
})

// @see https://vueuse.org/core/useEventListener/
useEventListener(window, 'keydown', (event) => {
  if (event.key === 'Escape') {
    close()
  }
})

onUnmounted(() => {
  swiper?.destroy()
  focusTrap.deactivate()
})

function resizeNavigation() {
  navigationWidth.value = swiper?.slides[swiper.activeIndex].querySelector('img')?.offsetWidth ?? 0
}

function close() {
  closing.value = true
  nextTick(() => emit('close'))
}
</script>

I have also utilized some helpers from VueUse, such as useFocusTrap and useEventListener. To make everything work together, we need to install the following dependencies:

# Terminal

## pnpm
pnpm add swiper @vueuse/core focus-trap

## npm
npm i swiper @vueuse/core focus-trap

Now, let's integrate the Slider component into our Portfolio block. First, we'll wrap the Container in a div element and add the Slider component to it. We'll also include conditional logic and transitions for when the slider is opened:

# blocks/Portfolio.vue

<template>
  <div>
    <Container class="space-y-6 ph:space-y-5">
      ...

      <div class="grid grid-cols-3 gap-5 tp:grid-cols-2 ph:grid-cols-1">
        <button
          v-for="({ image, caption }, i) of gallery"
          @click="
            () => {
              sliderVisible = true
              activeSlide = i
            }
          "
          aria-label="Open image"
          class="group relative overflow-hidden rounded-md"
        >
          ...
        </button>
      </div>
    </Container>

    <Transition>
      <LazySlider
        v-if="sliderVisible && gallery"
        :active="activeSlide"
        :slides="gallery"
        @close="sliderVisible = false"
      />
    </Transition>
  </div>
</template>

<script lang="ts" setup>
...

const sliderVisible = ref(false)
const activeSlide = ref(0)
</script>

<style lang="postcss" scoped>
.v-enter-active {
  @apply transition-opacity duration-300;
}

.v-enter-from {
  @apply opacity-0;
}
</style>

Here's the final look of our block:

Playground

You can access the current project files at the following link:

Explore the GitHub repository.

Last updated on January 15, 2024 at 19:44