Slug
import { useState } from 'react';
const css = `
@keyframes field-slug-pop { from { opacity: 0; transform: translateY(-5px) scale(1.4); } }
.field-slug { width: 250px; }
.field-slug__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-slug__input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 15px;
transition: box-shadow 0.2s;
}
.field-slug__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-slug__url {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529);
}
.field-slug__path { flex: 1; min-width: 0; color: light-dark(#5f626a, #a7abb2); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
.field-slug__path b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.field-slug__ch { display: inline-block; animation: field-slug-pop 0.3s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-slug__copy {
flex: none;
padding: 3px 8px;
border: 0;
border-radius: 6px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#42454c, #e5e6e9);
font-family: inherit; font-size: 10px; font-weight: 600;
cursor: pointer;
}
.field-slug__copy:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .field-slug__ch { animation: none; } }
`;
const slugify = (s: string) =>
s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
export function SlugInput() {
const [value, setValue] = useState('Motion Design: 10 Tips & Tricks');
const [copied, setCopied] = useState(false);
const slug = slugify(value);
return (
<>
<style>
{css}
</style>
<div className="field-slug">
<label className="field-slug__label" htmlFor="field-slug-input">
Post title
</label>
<input
id="field-slug-input"
className="field-slug__input"
value={value}
spellCheck={false}
onChange={(e) => {
setValue(e.target.value);
setCopied(false);
}}
/>
<div className="field-slug__url">
<span className="field-slug__path" aria-label={`Address: movin.design/blog/${slug}`}>
movin.design/blog/
<b>
{[...slug].map((c, i) => (
// A character only replays its pop when it actually changes.
<span key={`${i}${c}`} className="field-slug__ch">
{c}
</span>
))}
</b>
</span>
<button
type="button"
className="field-slug__copy"
onClick={() => {
navigator.clipboard?.writeText(`https://movin.design/blog/${slug}`).catch(() => {});
setCopied(true);
}}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldSlug(el) on the mounted element instead.
<div class="field-slug">
<label class="field-slug__label" for="field-slug-input">Post title</label>
<input id="field-slug-input" class="field-slug__input" spellCheck="false" value="Motion Design: 10 Tips & Tricks"/>
<div class="field-slug__url">
<span class="field-slug__path" aria-label="Address: movin.design/blog/motion-design-10-tips-and-tricks">movin.design/blog/<b><span class="field-slug__ch">m</span><span class="field-slug__ch">o</span><span class="field-slug__ch">t</span><span class="field-slug__ch">i</span><span class="field-slug__ch">o</span><span class="field-slug__ch">n</span><span class="field-slug__ch">-</span><span class="field-slug__ch">d</span><span class="field-slug__ch">e</span><span class="field-slug__ch">s</span><span class="field-slug__ch">i</span><span class="field-slug__ch">g</span><span class="field-slug__ch">n</span><span class="field-slug__ch">-</span><span class="field-slug__ch">1</span><span class="field-slug__ch">0</span><span class="field-slug__ch">-</span><span class="field-slug__ch">t</span><span class="field-slug__ch">i</span><span class="field-slug__ch">p</span><span class="field-slug__ch">s</span><span class="field-slug__ch">-</span><span class="field-slug__ch">a</span><span class="field-slug__ch">n</span><span class="field-slug__ch">d</span><span class="field-slug__ch">-</span><span class="field-slug__ch">t</span><span class="field-slug__ch">r</span><span class="field-slug__ch">i</span><span class="field-slug__ch">c</span><span class="field-slug__ch">k</span><span class="field-slug__ch">s</span></b></span>
<button type="button" class="field-slug__copy">Copy</button>
</div>
</div>@keyframes field-slug-pop { from { opacity: 0; transform: translateY(-5px) scale(1.4); } }
.field-slug { width: 250px; }
.field-slug__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-slug__input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 15px;
transition: box-shadow 0.2s;
}
.field-slug__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-slug__url {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529);
}
.field-slug__path { flex: 1; min-width: 0; color: light-dark(#5f626a, #a7abb2); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
.field-slug__path b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.field-slug__ch { display: inline-block; animation: field-slug-pop 0.3s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-slug__copy {
flex: none;
padding: 3px 8px;
border: 0;
border-radius: 6px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#42454c, #e5e6e9);
font-family: inherit; font-size: 10px; font-weight: 600;
cursor: pointer;
}
.field-slug__copy:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .field-slug__ch { animation: none; } }function initFieldSlug(root) {
var input = root.querySelector('.field-slug__input');
var path = root.querySelector('.field-slug__path');
var copy = root.querySelector('.field-slug__copy');
if (!input || !path || !copy) return;
var bold = path.querySelector('b');
function slugify(s) {
return s
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
var slug = '';
function update() {
var next = slugify(input.value);
var spans = bold.children;
// A character only replays its pop when it actually changes.
next.split('').forEach(function (c, i) {
if (spans[i] && slug[i] === c) return;
var s = document.createElement('span');
s.className = 'field-slug__ch';
s.textContent = c;
if (spans[i]) spans[i].replaceWith(s);
else bold.appendChild(s);
});
while (bold.children.length > next.length) bold.lastChild.remove();
slug = next;
path.setAttribute('aria-label', 'Address: movin.design/blog/' + slug);
copy.textContent = 'Copy';
}
function doCopy() {
if (navigator.clipboard) navigator.clipboard.writeText('https://movin.design/blog/' + slug).catch(function () {});
copy.textContent = 'Copied';
}
slug = slugify(input.value);
input.addEventListener('input', update);
copy.addEventListener('click', doCopy);
return function () {
input.removeEventListener('input', update);
copy.removeEventListener('click', doCopy);
};
}
document.querySelectorAll('.field-slug').forEach(initFieldSlug);Goal: Create a "Slug" input.
Visual behavior: A post-title field that builds its URL slug live underneath: accents are stripped, symbols become dashes, and each character of the slug pops in only when it actually changes. Copy puts the full link on the clipboard.
Technique:
- The slug is the title normalised (NFD, combining marks removed), lowercased, & spelled "and", every run of other characters turned into a dash, trimmed and capped at 60.
- Each slug character is a span keyed by its index and character, so only characters that change are re-created and replay a small pop.
- The copy button writes the full URL with the Clipboard API and says Copied until the next edit.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The address has an aria-label with the full URL, and Copy is a real button. Under prefers-reduced-motion characters do not pop.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Slug" input.
Visual behavior: A post-title field that builds its URL slug live underneath: accents are stripped, symbols become dashes, and each character of the slug pops in only when it actually changes. Copy puts the full link on the clipboard.
Technique:
- The slug is the title normalised (NFD, combining marks removed), lowercased, & spelled "and", every run of other characters turned into a dash, trimmed and capped at 60.
- Each slug character is a span keyed by its index and character, so only characters that change are re-created and replay a small pop.
- The copy button writes the full URL with the Clipboard API and says Copied until the next edit.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The address has an aria-label with the full URL, and Copy is a real button. Under prefers-reduced-motion characters do not pop.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A post-title field that builds its URL slug live underneath: accents are stripped, symbols become dashes, and each character of the slug pops in only when it actually changes. Copy puts the full link on the clipboard.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- focus, state change
- Techniques
- keyframes, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The address has an aria-label with the full URL, and Copy is a real button. Under prefers-reduced-motion characters do not pop.