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>