DEV Community

Cover image for Create Instagram Like Stories with Nuxt 3 and TailwindCSS
Lukas Mauser for Wimadev

Posted on • Updated on

Create Instagram Like Stories with Nuxt 3 and TailwindCSS

In this tutorial, I'll show you how to build a simplified stories component just like you know it from Instagram.

The result will look like this:

Wimadev Stories Component

Not only is it a fun little coding challenge, but interactive components like this also greatly enrich the user experience of your website or application. You could create an onboarding component to educate your customers about your product or service in a fun and interactive way.

So let's go!

I'm using Nuxt 3 and TailwindCSS.

Side note: Need a helping hand on your Nuxt 3 project? Contact me for experienced dev support: https://nuxt.wimadev.de 🙋🏼‍♂️

Setup the project

To get started, open a terminal and create a new Nuxt 3 project with:

npx nuxi@latest init stories
Enter fullscreen mode Exit fullscreen mode

I named the project stories here, but of course you can choose any name that you like.

Next, we install Tailwind. We'll just follow their official guide here: https://tailwindcss.com/docs/guides/nuxtjs.

In your terminal run:

cd stories
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

This will install all dependencies and init a tailwind.config.js file for us. Open the tailwind config file and add the following code:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./components/**/*.{js,vue,ts}",
    "./layouts/**/*.vue",
    "./pages/**/*.vue",
    "./plugins/**/*.{js,ts}",
    "./app.vue",
    "./error.vue",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

Create the assets folder in the root of your project and place a main.css file there. Add the following lines to it:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

And finally update your nuxt.config.ts:

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
  css: ['~/assets/css/main.css'],
  postcss: {
    plugins: {
      tailwindcss: {},
      autoprefixer: {},
    },
  },
})
Enter fullscreen mode Exit fullscreen mode

Create Testdata

The finished story component can look overwhelming at first, so let's break it down into small sub problems.

I usually start thinking about the underlying data set first.

We need to display different stories. Each story contains different slides with images. That's already it!

I will also add id's to our stories and slides, so that we have some unique identifiers for them, when trying to render them in a v-for-loop later.

Our data will be shaped like this:

interface Slide {
  id: string;
  imgUrl: string;
}

interface Story {
  id: string;
  slides: Slide[]
}

Enter fullscreen mode Exit fullscreen mode

So let's start by grabbing some example images. I placed them inside Nuxt's public directory and created the following data set:

const stories = ref<Story[]>([
  {
    id: "story-1",
    slides: [
      { id: "slide-1", imgUrl: "bowl.jpeg" },
      { id: "slide-2", imgUrl: "eggs.jpg" },
      { id: "slide-3", imgUrl: "berlin.jpg" },
    ],
  },
  {
    id: "story-2",
    slides: [
      { id: "slide-4", imgUrl: "builder.png" },
      { id: "slide-5", imgUrl: "coder.jpg" },
    ],
  },
  {
    id: "story-3",
    slides: [
      { id: "slide-6", imgUrl: "berlin.jpg" },
      { id: "slide-7", imgUrl: "bowl.jpeg" },
      { id: "slide-8", imgUrl: "eggs.jpg" },
    ],
  },
  {
    id: "story-4",
    slides: [
      { id: "slide-9", imgUrl: "coder.jpg" },
      { id: "slide-10", imgUrl: "builder.png" },
    ],
  },
]);
Enter fullscreen mode Exit fullscreen mode

Creating a functional frame

Now that we have some data to play around with, let's continue with some basic functionality.

I will start by rendering all stories and slides and apply some simple styling to be able to tell them apart.

<template>
  <div>

    <!--stories-->
    <div
      v-for="(story, index) in stories"
      :key="story.id"
      class="p-4 border bg-red-200"
    >
      {{ story.id }}

      <!--slides-->
      <div
        v-for="slide in story.slides"
        :key="slide.id"
        class="p-4 border bg-blue-200"
      >
        {{ slide.id }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">

interface Slide {
  id: string;
  imgUrl: string;
}

interface Story {
  id: string;
  slides: Slide[];
}

// example data set
const stories = ref<Story[]>([...]);

</script>
Enter fullscreen mode Exit fullscreen mode

The result looks like this:

Image description

Next, let's implement some basic navigation functionality.

We must keep track of which story and which slide is currently in focus. To do so, we will introduce a new variable const currentStoryIndex = ref(0) to keep track of the current story.

Also, because we will probably need it a bit, let's create a computed property to get the current story in focus:

const currentStory = computed(()=>{
   return stories.value[currentStoryIndex.value]
})
Enter fullscreen mode Exit fullscreen mode

To keep track of our active slide, we will extend our story interface with a new property currentSlideIndex:

interface Story {
   id: string;
   slides: Slide[];
   currentSlideIndex: number;
}
Enter fullscreen mode Exit fullscreen mode

Remember to update your test data as well!

We can then add some simple navigation logic. Adding and subtracting indices and making sure we don't go out of bounds:

function nextStory() {
  if (currentStoryIndex.value < stories.value.length - 1) {
    currentStoryIndex.value++;
  }
}

function prevStory() {
  if (currentStoryIndex.value > 0) {
    currentStoryIndex.value--;
  }
}

function nextSlide() {
  if (
    currentStory.value.currentSlideIndex <
    currentStory.value.slides.length - 1
  )
    stories.value[currentStoryIndex.value].currentSlideIndex++;
  else if (
    currentStory.value.currentSlideIndex ===
    currentStory.value.slides.length - 1
  ) {
    nextStory();
  }
}

function prevSlide() {
  if (currentStory.value.currentSlideIndex > 0)
    stories.value[currentStoryIndex.value].currentSlideIndex--;
  else if (currentStory.value.currentSlideIndex === 0) prevStory();
}
Enter fullscreen mode Exit fullscreen mode

Basic styling + Refactoring

Time to add some basic styling!

While styling everything, my initial file grew quite a bit, so I decided to split it into the following 3 parts:

1. Page

Our Page contains all stories, as well as the navigation logic that I described earlier. Events from Story components get passed up to Page parent through $emits and values get passed down the component chain through properties. I don't work with Vue's provide and inject functions here, since they introduce a little bit of unnecessary complexity for this tutorial:

<template>
  <div
    class="h-screen flex items-center bg-black bg-opacity-50"
  >
    <!--stories-->
    <Story
      v-for="(story, index) in stories"
      :key="story.id"
      :story="story"
      :focused="index === currentStoryIndex"
      @prev-story="prevStory"
      @next-story="nextStory"
      @prev-slide="prevSlide"
      @next-slide="nextSlide"
    />
  </div>
</template>

<script setup lang="ts">
interface Slide {
  id: string;
  imgUrl: string;
}

interface Story {
  id: string;
  slides: Slide[];
  currentSlideIndex: number;
}

// example data set
const stories = ref<Story[]>([...]);

// track currently focused story
const currentStoryIndex = ref(0);

const currentStory = computed(() => {
  return stories.value[currentStoryIndex.value];
});

// navigate between slides on story

function nextSlide() {
  if (
    currentStory.value.currentSlideIndex <
    currentStory.value.slides.length - 1
  )
    stories.value[currentStoryIndex.value].currentSlideIndex++;
  else if (
    currentStory.value.currentSlideIndex ===
    currentStory.value.slides.length - 1
  ) {
    nextStory();
  }
}

function prevSlide() {
  if (currentStory.value.currentSlideIndex > 0)
    stories.value[currentStoryIndex.value].currentSlideIndex--;
  else if (currentStory.value.currentSlideIndex === 0) prevStory();
}

// navigate between stories

function nextStory() {
  if (currentStoryIndex.value < stories.value.length - 1) {
    currentStoryIndex.value++;
  }
}

function prevStory() {
  if (currentStoryIndex.value > 0) {
    currentStoryIndex.value--;
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode

2. Story

As you already saw above, inside our Page there is a Story Component. It contains the story's slides, navigation buttons to move to the next and previous story, as well as some logic to visually distinguish the currently focused story, from the others.

<template>
  <div class="w-96 rounded-xl flex-shrink-0 flex justify-center">
    <div class="relative flex rounded-xl overflow-hidden">
      <!--black overlay for non focused stories-->
      <div
        v-if="!focused && story.slides.length > 0"
        class="bg-black opacity-50 absolute inset-0 z-40"
      ></div>

      <!--prev story button-->
      <button v-if="focused" @click="$emit('prevStory')" class="text-gray-200">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="M11.25 9l-3 3m0 0l3 3m-3-3h7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
          />
        </svg>
      </button>

      <StorySlides
        :slides="story.slides"
        :story-focused="focused"
        :current-slide-index="story.currentSlideIndex"
        @next-slide="$emit('nextSlide')"
        @prev-slide="$emit('prevSlide')"
      />

      <!--snext story button-->
      <button v-if="focused" @click="$emit('nextStory')" class="text-gray-200">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="M12.75 15l3-3m0 0l-3-3m3 3h-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
          />
        </svg>
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
interface Slide {
  id: string;
  imgUrl: string;
}

interface Story {
  id: string;
  slides: Slide[];
  currentSlideIndex: number;
}

defineProps<{ story: Story; focused?: boolean }>();
</script>

Enter fullscreen mode Exit fullscreen mode

3. StorySlides

Finally, there is a component that holds all story slides. It renders all slide images on top of each other, and puts the active slide image in focus by applying the highest z-index.

Take note, that this approach leads to a snappy UI, but all images are downloaded at once. This could lead to a long initial loading time. I won't dive into performance optimization here, but it's good to keep in mind, where improvements could be made.

Just like in the Story component, navigation events get passed up the component chain through the $emit function.

Finally our StorySlides component contains a little progress indicator on top, showing what slide is currently in focus.

<template>
  <!--story slides-->
  <div class="relative h-[50vh] rounded-lg overflow-hidden w-64">
    <!--slide indicators-->
    <div
      v-if="storyFocused"
      class="z-30 absolute top-10 w-full px-2 flex space-x-1"
    >
      <div
        v-for="(slide, slideIndex) in slides"
        :key="slide.id + '-indicator'"
        class="h-1 rounded w-full cursor-pointer hover:bg-opacity-90"
        :class="[
          slideIndex <= currentSlideIndex ? 'bg-gray-100' : 'bg-gray-600',
        ]"
      ></div>
    </div>

    <!--prev/next slide buttons-->
    <button
      class="z-20 absolute h-full w-1/2 left-0 top-0"
      @click.stop="$emit('prevSlide')"
    ></button>
    <button
      class="z-20 absolute h-full w-1/2 right-0 top-0"
      @click.stop="$emit('nextSlide')"
    ></button>

    <!--slide images-->
    <div
      v-for="(slide, slideIndex) in slides"
      :key="slide.id"
      class="absolute top-0 left-0 h-full w-full"
      :class="{ 'z-10': currentSlideIndex === slideIndex }"
    >
      <img class="h-full w-full bg-black object-contain" :src="slide.imgUrl" />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Slide {
  id: string;
  imgUrl: string;
}
defineProps<{
  slides: Slide[];
  storyFocused?: boolean;
  currentSlideIndex: number;
}>();
</script>
Enter fullscreen mode Exit fullscreen mode

The result looks like this:

Image description

We have a fully functional stories component, all that's missing is to give it some life and introduce some movement to it!

Add Transition Animations

Everything that moves is usually a bit intimidating at first, but bare with me, in this case it will be quite simple to follow.

The animation here is not coming from an overflowing div, that is moved across the screen.

Instead think of 7 fixed story slots and each time we progress to the next story, we use tailwinds build in transition utilities to shift all of them to the left or right.

At the end of each transition, we'll quickly swap out all of the stories for the next one and create an illusion of a moving strip of content. Vue's build in '@transitionend' hook comes in very handy for that.

So let's dive into implementation!

We start by introducing a visibleStories property, that keeps track of the 7 story slots that are displayed.

Hide the overflow, center everything, refactor our currentStory and we end up here:

<template>
  <div
    class="relative h-screen flex items-center bg-black bg-opacity-50 overflow-x-hidden"
  >
    <div class="absolute flex left-1/2 -translate-x-1/2">
      <!--stories-->
      <Story
        v-for="(story, index) in visibleStories"
        :key="story.id"
        :focused="index === 3"
        :story="story"
        @next-slide="nextSlide"
        @prev-slide="prevSlide"
        @next-story="nextStory"
        @prev-story="prevStory"
      />
    </div>
  </div>
</template>


<script setup lang="ts">

//...

// placeholder to put into empty story slots before and after
// our current story
const emptyStorySlot: Story = { id: "empty", slides: [], currentSlideIndex: 0 };

// we only display 7 stories
const visibleStories = computed(() => {
  const extendedStories = [
    emptyStorySlot,
    emptyStorySlot,
    emptyStorySlot,
    ...stories.value,
    emptyStorySlot,
    emptyStorySlot,
    emptyStorySlot,
  ];
  return extendedStories.slice(
    currentStoryIndex.value,
    currentStoryIndex.value + 7
  );
});

// get the story that is in focus
const currentStory = computed(() => {
  return visibleStories.value[3];
});

function nextStory() {
  if (
    currentStoryIndex.value <
    visibleStories.value.length - stories.value.length
  ) {
    currentStoryIndex.value++
  }
}

function prevStory() {
  if (currentStoryIndex.value > 0) {
    currentStoryIndex.value--
  }
}
<script>

Enter fullscreen mode Exit fullscreen mode

All that's left now, is to introduce the transition animation!

In our Page, we add two more variables, telling us whether to move left or right:

const moveLeft = ref(false)
const moveRight = ref(false)
Enter fullscreen mode Exit fullscreen mode

Then, we'll update our story slots, to transition to the left or right, when these variables are triggered:

<Story
   ...
   :class="{
      'transition-all duration-300 -translate-x-full': moveLeft,
      'transition-all duration-300 translate-x-full': moveRight,
   }"
Enter fullscreen mode Exit fullscreen mode

Now we need to modify our nextStory and prevStory functions. We don't want to transition to the next story right away, but rather trigger the movement first, and only when the transition finished, increase the currentStoryIndex:

function nextStory() {
  if (currentStoryIndex.value < visibleStories.value.length - stories.value.length) {
    moveLeft.value = true;
  }
}

function prevStory() {
  if (currentStoryIndex.value > 0) {
    moveRight.value = true;
  }
}

function transitionend(index: number) {
  // only run this function once
  if (index > 0) return;

  if (moveLeft.value) {
    currentStoryIndex.value++;
    moveLeft.value = false;
  } else if (moveRight.value) {
    currentStoryIndex.value--;
    moveRight.value = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

Take note that I introduces a new function transitionend here. We will trigger this by adding the @transitionend vue hook to our Story:

<Story
        ...
        :class="{
          'transition-all duration-300 -translate-x-full': moveLeft,
          'transition-all duration-300 translate-x-full': moveRight,
        }"
        @transitionend="transitionend(index)"
      />

Enter fullscreen mode Exit fullscreen mode

And that's basically it! After a few more styling tweaks, to scale the stories as they enter and leave the current story slot, we end up with this:

<template>
  <div
    class="relative h-screen flex items-center bg-black bg-opacity-50 overflow-x-hidden"
  >
    <div class="absolute flex left-1/2 -translate-x-1/2">
      <div class="fixed top-0 left-0 z-50 text-white"></div>

      <!--stories-->
      <Story
        v-for="(story, index) in visibleStories"
        :key="story.id"
        :class="{
          'transition-all duration-300 -translate-x-full': moveLeft,
          'transition-all duration-300 translate-x-full': moveRight,
          'scale-150':
            (index == 3 && !moveLeft && !moveRight) ||
            (index == 4 && moveLeft) ||
            (index == 2 && moveRight),
        }"
        :focused="index === 3 && !moveLeft && !moveRight"
        :fade-overlay="(index == 4 && moveLeft) || (index == 2 && moveRight)"
        :story="story"
        @next-story="nextStory"
        @prev-story="prevStory"
        @next-slide="nextSlide"
        @prev-slide="prevSlide"
        @transitionend="transitionend(index)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Slide {
  id: string;
  imgUrl: string;
}

interface Story {
  id: string;
  slides: Slide[];
  currentSlideIndex: number;
}

// example data set
const stories = ref<Story[]>([
  {
    id: "story-1",
    slides: [
      { id: "slide-1", imgUrl: "bowl.jpeg" },
      { id: "slide-2", imgUrl: "eggs.jpg" },
      { id: "slide-3", imgUrl: "berlin.jpg" },
    ],
    currentSlideIndex: 0,
  },
  {
    id: "story-2",
    slides: [
      { id: "slide-4", imgUrl: "builder.png" },
      { id: "slide-5", imgUrl: "coder.jpg" },
    ],
    currentSlideIndex: 0,
  },
  {
    id: "story-3",
    slides: [
      { id: "slide-6", imgUrl: "berlin.jpg" },
      { id: "slide-7", imgUrl: "bowl.jpeg" },
      { id: "slide-8", imgUrl: "eggs.jpg" },
    ],
    currentSlideIndex: 0,
  },
  {
    id: "story-4",
    slides: [
      { id: "slide-9", imgUrl: "coder.jpg" },
      { id: "slide-10", imgUrl: "builder.png" },
    ],
    currentSlideIndex: 0,
  },
]);

// keep track of story in focus
const currentStoryIndex = ref(0);

const currentStory = computed(() => {
  return stories.value[currentStoryIndex.value];
});

// placeholder to add to empty story slots

const emptyStorySlot: Story = { id: "empty", slides: [], currentSlideIndex: 0 };

// we only display 7 stories
const visibleStories = computed(() => {
  const extendedStories = [
    empty,
    empty,
    empty,
    ...stories.value,
    empty,
    empty,
    empty,
  ];
  return extendedStories.slice(
    currentStoryIndex.value,
    currentStoryIndex.value + 7
  );
});

// get the story that is in focus
const currentStory = computed(() => {
  return visibleStories.value[3];
});

// navigate through slides in story

function nextSlide() {
  if (
    currentStory.value.currentSlideIndex <
    currentStory.value.slides.length - 1
  )
    stories.value[3].currentSlideIndex++;
  else if (
    currentStory.value.currentSlideIndex ===
    currentStory.value.slides.length - 1
  ) {
    nextStory();
  }
}

function prevSlide() {
  if (currentStory.value.currentSlideIndex > 0)
    stories.value[3].currentSlideIndex--;
  else if (currentStory.value.currentSlideIndex === 0) prevStory();
}

// transition stories to left/ right

const moveLeft = ref(false);
const moveRight = ref(false);

// navigate through stories

function nextStory() {
  if (currentStoryIndex.value <
    visibleStories.value.length - stories.value.length) {
    moveLeft.value = true;
  }
}

function prevStory() {
  if (currentStoryIndex.value > 0) {
    moveRight.value = true;
  }
}

// swap stories when transition ends

function transitionend(index: number) {
  // run only once
  if (index > 0) return;

  if (moveLeft.value) {
    currentStoryIndex.value++;
    moveLeft.value = false;
  } else if (moveRight.value) {
    currentStoryIndex.value--;
    moveRight.value = false;
  }
}
</script>

Enter fullscreen mode Exit fullscreen mode

Wimadev Stories Component

Summary

To sum it all up:

  1. Interactive components make your website or application alive and fun to use
  2. Building them can be intimidating at first, but when you break it down into small problems, they become very manageable
  3. We started with thinking about how the underlying data needs to look and created a test data set to work with
  4. Next, we implemented some wireframes without styling them or thinking about any animations
  5. Once everything worked, we introduced some basic styling for our code and refactored it into 3 sub components in order to stay ahead of the introduced complexity
  6. To finish it off, we added the animation which consists of defining 7 fixed slots for each story, then transition each slot to the side and swapping the stories inside these slots after the transition ended.

And that's it!

Of course there is always something to improve upon. I mentioned coming up with a more network friendly loading strategy or making parts of the code a bit more tidy. Do you have any recommendations? Let me know in the comments!

Also, if you need any help with your Nuxt.js project, feel free to reach out to me on https://nuxt.wimadev.de

Cheers ✌🏻

Top comments (0)