Create Instagram Like Stories with Nuxt 3 and TailwindCSS
Tags: nuxt, tailwindcss, tutorial, frontend
Published: 2023-10-18 14:56:42

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ixeicfrfgr0rr1rt6j9i.gif)

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](https://nuxt.wimadev.de?utm_source=stories-component) 🙋🏼‍♂️


## Setup the project

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

```bash
npx nuxi@latest init stories
```

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:

```bash
cd stories
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
```

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

```javascript
/** @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: [],
}
```

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

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
And finally update your `nuxt.config.ts`:

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

## 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:

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

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

```

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

```typescript
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" },
    ],
  },
]);
```

## 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.

```vue
<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>
```

The result looks like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3fdjpxe5mtikruno3hm6.png)
 
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:

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

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

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

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:

```typescript
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();
}
```

## 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 `$emit`s 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:

```vue
<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>

```

### 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. 

```vue
<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>

```

### 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.
```vue
<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>
```

The result looks like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ss3ii4srt8afy55pdf5i.png)

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:

```vue
<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>

```

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: 

```typescript
const moveLeft = ref(false)
const moveRight = ref(false)
```

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

```vue
<Story
   ...
   :class="{
      'transition-all duration-300 -translate-x-full': moveLeft,
      'transition-all duration-300 translate-x-full': moveRight,
   }"
```

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`:

```typescript
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;
  }
}
```

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

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

```

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:

```vue
<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>

``` 

![Wimadev Stories Component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ixeicfrfgr0rr1rt6j9i.gif)


## 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](https://nuxt.wimadev.de?utm_source=stories-component)

Cheers ✌🏻