Back to list

Building a slug editor input with Nuxt 3 & Tailwind


When building a Nuxt 3 app recently, I’ve had to build an input allowing users to edit slugs. Slugs are the part of a URL that identifies a page, and they’re usually lowercase and contain only letters, numbers, and dashes. For example, the slug for this post is slug-editor-nuxt.

For this tutorial, I use Nuxt v3.1.0 & Tailwind v2.2.19. You can find the repo here.

Building our slug editor input

Component

Into the components/ folder, create a new file called SlugEditor.vue and add the following code:

<template>
  <div class="flex flex-col gap-1 w-fit">
    <label class="font-medium text-xs text-slate-500">URL</label>
    <div class="bg-slate-100 border-none rounded-md px-4 py-3 text-sm flex">
      <p class="text-slate-400">http://localhost:3000/users/</p>
      <div class="relative">
        <label for="" class="w-full px-1 pointer-events-none">{{ slug }}</label>
        <input
          ref="slugInput"
          @change="handleSlug"
          class="absolute top-0 left-0 w-full px-1 text-transparent caret-green-500 bg-transparent border-none text-sm outline-none"
          type="text"
          contenteditable
          v-model="slug"
        />
      </div>
      <p class="text-slate-400">/manage</p>
    </div>
  </div>
</template>

Resizable input

Here we have the main structure, and we face the first challenge: how to handle a resizable input. A problem that might seem easy but that actually give some headaches. We’ll see how I solved it.

The <input> cannot resize by default, so we put it inside a <div> with position: relative and we put a <label> inside it with position: absolute and pointer-events: none. This way, the <label> will be on top of the <input> and will resize with it. The <label> will be transparent and will contain the slug, and the <input> will be transparent and will contain the caret. The <input> will be on top of the <label> and will be used to handle the user input.

// Same as above - no changes
<div class="relative">
  <label for="" class="w-full px-1 pointer-events-none">{{ slug }}</label>
  <input
    ref="slugInput"
    @change="handleSlug"
    class="absolute top-0 left-0 w-full px-1 text-transparent caret-green-500 bg-transparent border-none text-sm outline-none"
    type="text"
    contenteditable
    v-model="slug"
  />
</div>

Handling the slug

Now we need to handle the slug. We want to allow only lowercase letters, numbers, and dashes. We also want to remove any special characters. To help us and not reinvent the wheel, let’s install slugify

npm install slugify // yarn add slugify

Then, we can import it in our component so we can use it to handle the slug:

import slugify from "slugify";

Now, let’s add the logic :

const slug = ref("etienne");

const slugInput = ref();

const handleSlug = () => {
  const normalizedSlug = slugify((slugInput.value as HTMLInputElement).value, {
    lower: true,
  });
  slug.value = normalizedSlug;
};

This piece of code creates a ref with the initial slug, and that will contains the normalized one. It also creates a ref with the <input> element. Then, it creates a function that will be called when the <input> value changes. It will normalize the slug and update the slug ref.

Finally, we need to call the function when the <input> value changes. We can do that by adding @change="handleSlug" to the <input>.

Result

If you’re curious, you can check at the online exemple , but here is the final component :

<template>
  <div class="flex flex-col gap-1 w-fit">
    <label class="font-medium text-xs text-slate-500">URL</label>
    <div class="bg-slate-100 border-none rounded-md px-4 py-3 text-sm flex">
      <p class="text-slate-400">http://localhost:3000/users/</p>
      <div class="relative">
        <label for="" class="w-full px-1 pointer-events-none">{{ slug }}</label>
        <input
          ref="slugInput"
          @change="handleSlug"
          class="absolute top-0 left-0 w-full px-1 text-transparent caret-green-500 bg-transparent border-none text-sm outline-none"
          type="text"
          contenteditable
          v-model="slug"
        />
      </div>
      <p class="text-slate-400">/manage</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import slugify from "slugify";

const slug = ref("etienne");
const slugInput = ref();
const handleSlug = () => {
  const normalizedSlug = slugify((slugInput.value as HTMLInputElement).value, {
    lower: true,
  });
  slug.value = normalizedSlug;
};
</script>