URL
- secure
- movin.design
- components
- toggles
- sortnew
import { useState } from 'react';
const css = `
@keyframes field-url-in { from { opacity: 0; transform: translateY(5px) scale(0.92); } }
.field-url { width: 256px; }
.field-url__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-url__input {
width: 100%;
height: 40px;
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: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
transition: box-shadow 0.2s;
}
.field-url__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-url__parts { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 58px; margin: 8px 0 0; padding: 0; list-style: none; }
.field-url__part {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
border-radius: 6px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
animation: field-url-in 0.3s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.field-url__part svg { width: 11px; height: 11px; }
.field-url__part[data-kind='host'] { background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); font-weight: 600; }
.field-url__part[data-kind='query'] { background: none; box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41); color: light-dark(#42454c, #e5e6e9); }
.field-url__part[data-kind='query'] b { color: light-dark(#5f626a, #a7abb2); font-weight: 400; }
.field-url__sep { color: light-dark(#6d7179, #7d8087); font-size: 11px; }
@media (prefers-reduced-motion: reduce) { .field-url__part { animation: none; } }
.field-url__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-url__top > :first-child { margin: 0; }
.field-url__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-url__info svg { width: 14px; height: 14px; }
.field-url__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-url__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-url__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-url__info:hover::after, .field-url__info:focus-visible::after { opacity: 1; transform: none; }
`;
function parse(raw: string) {
const s = raw.trim();
if (!s || /\s/.test(s)) return null;
try {
const u = new URL(/^[a-z]+:\/\//i.test(s) ? s : `https://${s}`);
if (!u.hostname.includes('.')) return null;
return u;
} catch {
return null;
}
}
export function UrlInput() {
const [value, setValue] = useState('https://movin.design/components/toggles?sort=new');
const u = parse(value);
const segs = u ? u.pathname.split('/').filter(Boolean) : [];
const params = u ? [...u.searchParams.entries()] : [];
let n = 0;
return (
<>
<style>
{css}
</style>
<div className="field-url">
<div className="field-url__top">
<label className="field-url__label" htmlFor="field-url-input">
Link
</label>
<button type="button" className="field-url__info" aria-label="Paste a link to split it" data-tip="Paste a link to split it">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5v.01" />
</svg>
</button>
</div>
<input
id="field-url-input"
className="field-url__input"
type="url"
value={value}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
{u ? (
<ul className="field-url__parts" aria-label="Address parts">
<li className="field-url__part" style={{ animationDelay: `${n++ * 50}ms` }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
{u.protocol === 'https:' ? <path d="M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5z" /> : <path d="M7 11V8a5 5 0 0 1 9.5-2M5 11h14v10H5z" />}
</svg>
{u.protocol === 'https:' ? 'secure' : u.protocol.replace(':', '')}
</li>
<li key={u.hostname} className="field-url__part" data-kind="host" style={{ animationDelay: `${n++ * 50}ms` }}>
{u.hostname.replace(/^www\./, '')}
</li>
{segs.map((s, i) => (
<li key={`${i}${s}`} style={{ display: 'contents' }}>
<span className="field-url__sep" aria-hidden="true">
/
</span>
<span className="field-url__part" style={{ animationDelay: `${n++ * 50}ms` }}>
{decodeURIComponent(s)}
</span>
</li>
))}
{params.map(([k, v]) => (
<li key={`${k}=${v}`} className="field-url__part" data-kind="query" style={{ animationDelay: `${n++ * 50}ms` }}>
<b>{k}</b>
{v}
</li>
))}
</ul>
) : (
<div className="field-url__parts" />
)}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldUrl(el) on the mounted element instead.
<div class="field-url">
<div class="field-url__top">
<label class="field-url__label" for="field-url-input">Link</label>
<button type="button" class="field-url__info" aria-label="Paste a link to split it" data-tip="Paste a link to split it">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 7.5v.01"></path>
</svg>
</button>
</div>
<input id="field-url-input" class="field-url__input" type="url" autoComplete="off" spellCheck="false" value="https://movin.design/components/toggles?sort=new"/>
<ul class="field-url__parts" aria-label="Address parts">
<li class="field-url__part" style="animation-delay:0ms"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5z"></path></svg>secure</li>
<li class="field-url__part" data-kind="host" style="animation-delay:50ms">movin.design</li>
<li style="display:contents">
<span class="field-url__sep" aria-hidden="true">/</span>
<span class="field-url__part" style="animation-delay:100ms">components</span>
</li>
<li style="display:contents">
<span class="field-url__sep" aria-hidden="true">/</span>
<span class="field-url__part" style="animation-delay:150ms">toggles</span>
</li>
<li class="field-url__part" data-kind="query" style="animation-delay:200ms"><b>sort</b>new</li>
</ul>
</div>@keyframes field-url-in { from { opacity: 0; transform: translateY(5px) scale(0.92); } }
.field-url { width: 256px; }
.field-url__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-url__input {
width: 100%;
height: 40px;
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: 13px ui-monospace, SFMono-Regular, Menlo, monospace;
transition: box-shadow 0.2s;
}
.field-url__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-url__parts { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 58px; margin: 8px 0 0; padding: 0; list-style: none; }
.field-url__part {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
border-radius: 6px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
font: 11px ui-monospace, SFMono-Regular, Menlo, monospace;
animation: field-url-in 0.3s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.field-url__part svg { width: 11px; height: 11px; }
.field-url__part[data-kind='host'] { background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); font-weight: 600; }
.field-url__part[data-kind='query'] { background: none; box-shadow: inset 0 0 0 1px light-dark(#cbced3, #393c41); color: light-dark(#42454c, #e5e6e9); }
.field-url__part[data-kind='query'] b { color: light-dark(#5f626a, #a7abb2); font-weight: 400; }
.field-url__sep { color: light-dark(#6d7179, #7d8087); font-size: 11px; }
@media (prefers-reduced-motion: reduce) { .field-url__part { animation: none; } }
.field-url__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-url__top > :first-child { margin: 0; }
.field-url__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-url__info svg { width: 14px; height: 14px; }
.field-url__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-url__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-url__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-url__info:hover::after, .field-url__info:focus-visible::after { opacity: 1; transform: none; }function initFieldUrl(root) {
var input = root.querySelector('.field-url__input');
if (!input) return;
var LOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M7 11V8a5 5 0 0 1 10 0v3M5 11h14v10H5z"></path></svg>';
var OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M7 11V8a5 5 0 0 1 9.5-2M5 11h14v10H5z"></path></svg>';
function parse(raw) {
var s = raw.trim();
if (!s || /\s/.test(s)) return null;
try {
var u = new URL(/^[a-z]+:\/\//i.test(s) ? s : 'https://' + s);
return u.hostname.indexOf('.') >= 0 ? u : null;
} catch (e) {
return null;
}
}
function render() {
var old = root.querySelector('.field-url__parts');
var u = parse(input.value);
var list;
if (!u) {
list = document.createElement('div');
list.className = 'field-url__parts';
old.replaceWith(list);
return;
}
list = document.createElement('ul');
list.className = 'field-url__parts';
list.setAttribute('aria-label', 'Address parts');
var n = 0;
function chip(html, kind) {
var li = document.createElement('li');
li.className = 'field-url__part';
if (kind) li.setAttribute('data-kind', kind);
li.style.animationDelay = n++ * 50 + 'ms';
li.innerHTML = html;
return li;
}
function text(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
list.appendChild(chip((u.protocol === 'https:' ? LOCK : OPEN) + (u.protocol === 'https:' ? 'secure' : text(u.protocol.replace(':', '')))));
list.appendChild(chip(text(u.hostname.replace(/^www\./, '')), 'host'));
u.pathname.split('/').filter(Boolean).forEach(function (seg) {
var li = document.createElement('li');
li.style.display = 'contents';
var sep = document.createElement('span');
sep.className = 'field-url__sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '/';
var part = document.createElement('span');
part.className = 'field-url__part';
part.style.animationDelay = n++ * 50 + 'ms';
part.textContent = decodeURIComponent(seg);
li.appendChild(sep);
li.appendChild(part);
list.appendChild(li);
});
u.searchParams.forEach(function (v, k) {
list.appendChild(chip('<b>' + text(k) + '</b>' + text(v), 'query'));
});
old.replaceWith(list);
}
input.addEventListener('input', render);
return function () { input.removeEventListener('input', render); };
}
document.querySelectorAll('.field-url').forEach(initFieldUrl);Goal: Create a "URL" input.
Visual behavior: Paste a link and it breaks down below into chips: a lock for secure, the host highlighted, every path segment, and each query parameter as a key and value, each chip popping in one after another.
Technique:
- The value is parsed with the URL API (https:// is assumed when no scheme is typed); anything without a dotted host shows nothing.
- Path segments are decoded and joined by slashes; searchParams become outlined key/value chips.
- Every chip gets an animation-delay from its position, so they cascade in. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The chips are a list with an aria-label. Under prefers-reduced-motion they appear at once.
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 "URL" input.
Visual behavior: Paste a link and it breaks down below into chips: a lock for secure, the host highlighted, every path segment, and each query parameter as a key and value, each chip popping in one after another.
Technique:
- The value is parsed with the URL API (https:// is assumed when no scheme is typed); anything without a dotted host shows nothing.
- Path segments are decoded and joined by slashes; searchParams become outlined key/value chips.
- Every chip gets an animation-delay from its position, so they cascade in. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The chips are a list with an aria-label. Under prefers-reduced-motion they appear at once.
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
Paste a link and it breaks down below into chips: a lock for secure, the host highlighted, every path segment, and each query parameter as a key and value, each chip popping in one after another.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- focus, state change, delay stagger
- Techniques
- keyframes, transition delay stagger, state machine
- 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 chips are a list with an aria-label. Under prefers-reduced-motion they appear at once.