Choice Cards
Cards to compare and pick, on real radio buttons or checkboxes, with a title, a line and an optional price. The picked card's ink border draws itself around it from the corner while a check draws in its mark. Pure CSS.
View codeA real tel input with its country code in front and a searchable list of countries: the number groups itself as it is typed, a pasted or typed +44 picks the country, and the form gets the number in E.164.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Phone Input to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-phone" data-country="FR">
<label class="kodza-phone__box">
<span class="kodza-phone__label">Mobile</span>
<input class="kodza-phone__native" type="tel" name="mobile" autocomplete="tel" required>
</label>
<p class="kodza-phone__note">
<span class="kodza-phone__hint">We text one code. Not a newsletter.</span>
<span class="kodza-phone__error" aria-live="polite"></span>
</p>
</div>
<div class="kodza-phone" data-country="GB" data-countries="GB,IE,FR,BE,NL,DE,ES,IT,PT">
<label class="kodza-phone__box">
<span class="kodza-phone__label">Courier contact</span>
<input class="kodza-phone__native" type="tel" name="courier" autocomplete="tel" value="+44 7700 900123">
</label>
<p class="kodza-phone__note">
<span class="kodza-phone__hint">Only the courier sees it.</span>
<span class="kodza-phone__error" aria-live="polite"></span>
</p>
</div>
<div class="kodza-phone" data-countries="US">
<label class="kodza-phone__box">
<span class="kodza-phone__label">Mobile, US only</span>
<input class="kodza-phone__native" type="tel" name="us-mobile" autocomplete="tel">
</label>
<p class="kodza-phone__note">
<span class="kodza-phone__hint">US numbers only. For now.</span>
<span class="kodza-phone__error" aria-live="polite"></span>
</p>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Phone Input
A phone field with its country code on the left, in mono ("FR +33", no coloured flags). The
code opens a filtered list of countries (type a name, an ISO code or +44). The number groups
itself as it is typed, the way it is written in that country, with the rest of an example
number waiting in grey; type or paste "+44 7700…" and the field switches to that country on
its own. A tick draws itself once the number has the right length. A real tel input stays
underneath, and the form gets the full number (E.164, +447700900123) under its name.
Without JS, the tel input stays, styled. Same box as Text Field and Combobox.
Everything is tuned with the --kz-phone-* variables; the words, timings and countries, at the
top of the script. */
.kodza-phone {
--kz-phone-bg: #ffffff;
--kz-phone-fg: #0a0a0a;
--kz-phone-muted: rgba(10, 10, 10, 0.6);
--kz-phone-line: rgba(10, 10, 10, 0.14);
--kz-phone-line-hover: rgba(10, 10, 10, 0.3);
--kz-phone-soft: rgba(10, 10, 10, 0.06);
--kz-phone-error: #0a0a0a; /* a message, never colour alone: set a red here if you want one */
--kz-phone-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-phone-height: 56px;
--kz-phone-radius: calc(12px * var(--kz-radius-scale, 1));
--kz-phone-pad: 16px;
--kz-phone-width: var(--kz-field-width, 320px);
--kz-phone-list-height: 236px; /* the open list of countries, at most: it scrolls past that */
--kz-phone-duration: 0.35s;
--kz-phone-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: grid;
gap: 8px;
width: 100%;
max-width: var(--kz-phone-width);
/* The closed list keeps its size, ready to unroll, but never lengthens the page */
contain: layout;
/* A number, not auto, so it can wait for the list to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1));
color: var(--kz-phone-fg);
}
/* Open, the field rises above what follows it (its list included) */
.kodza-phone.is-open {
z-index: 20;
transition-delay: 0s;
}
/* The box: label on top, the country code and the number below */
.kodza-phone__box {
position: relative;
display: block;
box-sizing: border-box;
height: var(--kz-phone-height);
margin: 0;
padding: 0;
border-radius: var(--kz-phone-radius);
background: var(--kz-phone-bg);
box-shadow: inset 0 0 0 1px var(--kz-phone-line);
cursor: text;
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__box:hover,
.kodza-phone__code:hover ~ .kodza-phone__box {
box-shadow: inset 0 0 0 1px var(--kz-phone-line-hover);
}
/* Typing, or choosing a country: the field is the one in use (it beats the hover above) */
.kodza-phone .kodza-phone__box:focus-within,
.kodza-phone.is-open .kodza-phone__box {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-fg), 0 0 0 4px var(--kz-phone-soft);
}
.kodza-phone.is-invalid .kodza-phone__box {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-error);
}
.kodza-phone.is-invalid .kodza-phone__box:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-error), 0 0 0 4px var(--kz-phone-soft);
}
.kodza-phone.is-disabled .kodza-phone__box {
opacity: 0.5;
cursor: not-allowed;
}
.kodza-phone__label {
position: absolute;
top: 10px;
left: var(--kz-phone-pad);
z-index: 1;
color: var(--kz-phone-muted);
font-size: 12px;
line-height: 1;
pointer-events: none;
}
/* Without the script: the tel input fills the box, on the value line */
.kodza-phone__native {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 25px var(--kz-phone-pad) 9px;
border: 0;
border-radius: inherit;
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
letter-spacing: inherit;
font-variant-numeric: tabular-nums;
caret-color: var(--kz-phone-fg);
appearance: none;
}
.kodza-phone__native:disabled {
cursor: not-allowed;
}
/* The country code, on the value line: the ISO code in grey, the dialling code in ink */
.kodza-phone__code {
position: absolute;
top: calc(var(--kz-phone-height) - 33px);
left: 8px;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
height: 26px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: calc(8px * var(--kz-radius-scale, 1));
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 14px);
font-weight: var(--kz-font-label-weight, 400);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, inherit);
font-variant-numeric: tabular-nums;
cursor: pointer;
appearance: none;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__code:hover,
.kodza-phone.is-open .kodza-phone__code {
background: var(--kz-phone-soft);
}
.kodza-phone__code:focus-visible {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-fg);
}
/* One country only: the code is a plain label, nothing to open */
.kodza-phone__code.is-fixed {
cursor: default;
}
.kodza-phone__code.is-fixed:hover {
background: transparent;
}
.kodza-phone__code:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.kodza-phone__code:disabled:hover {
background: transparent;
}
/* The faces: a new country rolls in from below as the old one leaves upwards, and the button
eases to its new width */
.kodza-phone__faces {
display: grid;
width: var(--kz-phone-face, auto);
height: 26px;
overflow: hidden;
transition: width calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease);
}
.kodza-phone__face {
display: flex;
grid-area: 1 / 1;
align-items: center;
justify-self: start;
gap: 6px;
height: 26px;
white-space: nowrap;
}
.kodza-phone__iso {
color: var(--kz-phone-muted);
}
.kodza-phone__code svg {
display: block;
flex: none;
width: 12px;
height: 12px;
fill: none;
stroke: var(--kz-phone-muted);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease);
}
.kodza-phone.is-open .kodza-phone__code svg {
transform: rotate(180deg);
}
/* A hairline between the code and the number, riding on the code's edge */
.kodza-phone__sep {
position: absolute;
top: calc(var(--kz-phone-height) - 28px);
left: calc(var(--kz-phone-start, 92px) + 4px);
width: 1px;
height: 16px;
background: var(--kz-phone-line);
pointer-events: none;
transition: left calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease);
}
/* The number line, after the code: the tel input, and the rest of an example number under it */
.kodza-phone__field {
position: absolute;
right: 40px;
bottom: 9px;
left: calc(var(--kz-phone-start, 92px) + 14px);
height: 22px;
transition: left calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease);
}
.kodza-phone.is-ready .kodza-phone__native {
position: relative;
z-index: 1;
height: 22px;
padding: 0;
border-radius: 0;
}
/* The example: what is typed, kept invisible, then the digits still to come in grey */
.kodza-phone__example {
position: absolute;
inset: 0;
overflow: hidden;
color: var(--kz-phone-muted);
font-size: 15px;
line-height: 22px;
letter-spacing: inherit;
font-variant-numeric: tabular-nums;
white-space: pre;
opacity: 0;
pointer-events: none;
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone.is-hinting .kodza-phone__example {
opacity: 1;
}
.kodza-phone__typed {
color: transparent;
}
/* The tick: the number has the length it should have */
.kodza-phone__done {
position: absolute;
top: calc(var(--kz-phone-height) - 28px);
right: var(--kz-phone-pad);
width: 16px;
height: 16px;
fill: none;
stroke: var(--kz-phone-fg);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
pointer-events: none;
transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}
.kodza-phone.is-complete .kodza-phone__done {
stroke-dashoffset: 0;
transition: stroke-dashoffset calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-phone-ease) calc(0.05s * var(--kz-motion-scale, 1));
}
/* The list of countries unrolls from the field, over the note below it: a search on top */
.kodza-phone__pop {
position: absolute;
top: calc(var(--kz-phone-height) + 8px);
right: 0;
left: 0;
z-index: 20;
box-sizing: border-box;
overflow: hidden;
border-radius: calc(14px * var(--kz-radius-scale, 1));
background: var(--kz-phone-bg);
box-shadow: 0 0 0 1px var(--kz-phone-line), var(--kz-phone-shadow);
visibility: hidden;
clip-path: inset(0 0 100% 0 round calc(14px * var(--kz-radius-scale, 1)));
transform: translateY(-4px);
transition:
clip-path calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease),
transform calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease),
visibility 0s linear calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1));
}
.kodza-phone.is-open .kodza-phone__pop {
visibility: visible;
clip-path: inset(-40px -40px -60px -40px round calc(14px * var(--kz-radius-scale, 1)));
transform: none;
transition-delay: 0s;
}
.kodza-phone.is-above .kodza-phone__pop {
top: auto;
bottom: calc(100% + 8px);
clip-path: inset(100% 0 0 0 round calc(14px * var(--kz-radius-scale, 1)));
transform: translateY(4px);
}
.kodza-phone.is-above.is-open .kodza-phone__pop {
clip-path: inset(-60px -40px -40px -40px round calc(14px * var(--kz-radius-scale, 1)));
transform: none;
}
.kodza-phone__find {
position: relative;
display: block;
margin: 0;
box-shadow: inset 0 -1px 0 var(--kz-phone-line);
transition: box-shadow calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__find:focus-within {
box-shadow: inset 0 -1px 0 var(--kz-phone-fg);
}
/* The magnifier */
.kodza-phone__find::before {
content: "";
position: absolute;
top: 15px;
left: 16px;
width: 14px;
height: 14px;
background: var(--kz-phone-muted);
pointer-events: none;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='7' cy='7' r='4.75' fill='none' stroke='black' stroke-width='1.6'/%3E%3Cpath d='M10.5 10.5 14 14' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='7' cy='7' r='4.75' fill='none' stroke='black' stroke-width='1.6'/%3E%3Cpath d='M10.5 10.5 14 14' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-phone__search {
display: block;
box-sizing: border-box;
width: 100%;
height: 44px;
margin: 0;
padding: 0 14px 0 40px;
border: 0;
border-radius: 0;
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
font: inherit;
font-size: 14px;
line-height: 44px;
letter-spacing: inherit;
caret-color: var(--kz-phone-fg);
appearance: none;
}
.kodza-phone__search::placeholder {
color: var(--kz-phone-muted);
opacity: 1;
}
.kodza-phone__list {
position: relative;
box-sizing: border-box;
max-height: var(--kz-phone-list-height);
margin: 0;
padding: 6px;
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
}
.kodza-phone__highlight {
position: absolute;
top: 0;
right: 6px;
left: 6px;
height: var(--kz-hl-h, 36px);
border-radius: calc(8px * var(--kz-radius-scale, 1));
background: var(--kz-phone-soft);
opacity: var(--kz-hl-on, 0);
transform: translateY(var(--kz-hl-y, 0));
pointer-events: none;
}
.kodza-phone.is-open .kodza-phone__highlight {
transition:
transform calc(0.22s * var(--kz-motion-scale, 1)) var(--kz-phone-ease),
opacity calc(0.15s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__option {
position: relative;
display: flex;
align-items: center;
gap: 10px;
height: 36px;
margin: 0;
padding: 0 10px 0 32px;
border-radius: calc(8px * var(--kz-radius-scale, 1));
font-size: 14px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.kodza-phone__option[hidden],
.kodza-phone__empty[hidden] {
display: none;
}
/* The ISO code, where a flag would be */
.kodza-phone__abbr {
flex: none;
width: 2.2em;
color: var(--kz-phone-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, inherit);
}
.kodza-phone__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* The letters that match what was typed */
.kodza-phone__option mark {
padding: 0;
background: none;
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
/* The dialling code on the right */
.kodza-phone__meta {
flex: none;
margin-left: auto;
color: var(--kz-phone-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, inherit);
font-variant-numeric: tabular-nums;
}
/* The tick of the chosen country draws itself */
.kodza-phone__option svg {
position: absolute;
left: 10px;
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}
.kodza-phone__option[aria-selected="true"] svg {
stroke-dashoffset: 0;
transition: stroke-dashoffset calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-phone-ease) calc(0.05s * var(--kz-motion-scale, 1));
}
/* Nothing matches */
.kodza-phone__empty {
margin: 0;
padding: 18px 10px;
color: var(--kz-phone-muted);
font-size: 13px;
text-align: center;
}
/* Hint and error share one line: the hint steps out upward as the error rises in */
.kodza-phone__note {
display: grid;
margin: 0;
padding: 0 2px;
overflow: hidden;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: var(--kz-font-label-leading, 1.4);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-phone__hint,
.kodza-phone__error {
grid-area: 1 / 1;
transition:
opacity calc(var(--kz-phone-duration) * 0.625 * var(--kz-motion-scale, 1)) ease,
transform calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease);
}
.kodza-phone__hint {
color: var(--kz-phone-muted);
}
.kodza-phone__error {
display: flex;
align-items: center;
gap: 6px;
color: var(--kz-phone-error);
font-weight: var(--kz-font-label-weight, 500);
opacity: 0;
transform: translateY(100%);
}
.kodza-phone__error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-phone.is-invalid .kodza-phone__hint {
opacity: 0;
transform: translateY(-100%);
}
.kodza-phone.is-invalid .kodza-phone__error {
opacity: 1;
transform: none;
}
/* What screen readers hear: the count as the list filters, the country found in a pasted number */
.kodza-phone__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-phone,
.kodza-phone__box,
.kodza-phone__code,
.kodza-phone__faces,
.kodza-phone__code svg,
.kodza-phone__sep,
.kodza-phone__field,
.kodza-phone__example,
.kodza-phone__done,
.kodza-phone__pop,
.kodza-phone__find,
.kodza-phone.is-open .kodza-phone__highlight,
.kodza-phone__option svg,
.kodza-phone__hint,
.kodza-phone__error {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-phone {
--kz-phone-bg: #161616;
--kz-phone-fg: #f2f2f0;
--kz-phone-muted: rgba(242, 242, 240, 0.62);
--kz-phone-line: rgba(255, 255, 255, 0.14);
--kz-phone-line-hover: rgba(255, 255, 255, 0.3);
--kz-phone-soft: rgba(255, 255, 255, 0.08);
--kz-phone-error: #f2f2f0;
--kz-phone-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/
/* kodza-guard 77852f9b4cec — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-phone__highlight):not(#_) {
transition: revert;
}
:where(.kodza-phone__native):not(#_) {
position: revert;
}
:where(.kodza-phone__empty):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-phone__empty):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-phone:not(#_),
.kodza-phone__faces:not(#_),
.kodza-phone__face:not(#_),
.kodza-phone__iso:not(#_),
.kodza-phone__find:not(#_),
.kodza-phone__abbr:not(#_),
.kodza-phone__name:not(#_),
.kodza-phone__meta:not(#_),
.kodza-phone__label:not(#_),
.kodza-phone__sep:not(#_),
.kodza-phone__field:not(#_),
.kodza-phone__example:not(#_),
.kodza-phone__typed:not(#_),
.kodza-phone__hint:not(#_),
.kodza-phone__error:not(#_),
.kodza-phone__live:not(#_) {
box-sizing: revert;
}
.kodza-phone__code:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-phone__search:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
position: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-phone__list:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__highlight:not(#_) {
border: revert;
margin: revert;
padding: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__option:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__empty:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__box:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
float: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__native:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-phone__done:not(#_),
.kodza-phone__option svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__note:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone__code svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-phone input:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-phone__list:not(#_):where(:not([hidden])),
.kodza-phone__highlight:not(#_):where(:not([hidden])),
.kodza-phone__done:not(#_):where(:not([hidden])),
.kodza-phone__option svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-phone input:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-phone__list:not(#_):where([hidden]),
.kodza-phone__highlight:not(#_):where([hidden]),
.kodza-phone__done:not(#_):where([hidden]),
.kodza-phone__option svg:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-phone input:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
.kodza-phone__find:not(#_)::before,
.kodza-phone__error:not(#_)::before {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-phone:not(#_) {
width: min(100%, var(--kz-phone-width));
}
.kodza-phone__box:not(#_) {
position: relative;
display: block;
box-sizing: border-box;
margin: 0;
padding: 0;
border-radius: var(--kz-phone-radius);
background: var(--kz-phone-bg);
box-shadow: inset 0 0 0 1px var(--kz-phone-line);
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__box:hover:not(#_),
.kodza-phone__code:hover ~ .kodza-phone__box:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-phone-line-hover);
}
.kodza-phone .kodza-phone__box:focus-within:not(#_),
.kodza-phone.is-open .kodza-phone__box:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-fg), 0 0 0 4px var(--kz-phone-soft);
}
.kodza-phone.is-invalid .kodza-phone__box:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-error);
}
.kodza-phone.is-invalid .kodza-phone__box:focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-error), 0 0 0 4px var(--kz-phone-soft);
}
.kodza-phone__native:not(#_) {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 25px var(--kz-phone-pad) 9px;
border: 0;
border-radius: inherit;
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
letter-spacing: inherit;
font-variant-numeric: tabular-nums;
caret-color: var(--kz-phone-fg);
appearance: none;
}
.kodza-phone__native:disabled:not(#_) {
cursor: not-allowed;
}
.kodza-phone__code:not(#_) {
position: absolute;
top: calc(var(--kz-phone-height) - 33px);
left: 8px;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
height: 26px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: calc(8px * var(--kz-radius-scale, 1));
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 14px);
font-weight: var(--kz-font-label-weight, 400);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, inherit);
font-variant-numeric: tabular-nums;
cursor: pointer;
appearance: none;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__code:hover:not(#_),
.kodza-phone.is-open .kodza-phone__code:not(#_) {
background: var(--kz-phone-soft);
}
.kodza-phone__code:focus-visible:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-phone-fg);
}
.kodza-phone__code.is-fixed:not(#_) {
cursor: default;
}
.kodza-phone__code.is-fixed:hover:not(#_) {
background: transparent;
}
.kodza-phone__code:disabled:not(#_) {
opacity: 0.5;
cursor: not-allowed;
}
.kodza-phone__code:disabled:hover:not(#_) {
background: transparent;
}
.kodza-phone__code svg:not(#_) {
display: block;
transition: transform calc(var(--kz-phone-duration) * var(--kz-motion-scale, 1)) var(--kz-phone-ease);
}
.kodza-phone.is-ready .kodza-phone__native:not(#_) {
position: relative;
z-index: 1;
height: 22px;
padding: 0;
border-radius: 0;
}
.kodza-phone__done:not(#_) {
position: absolute;
transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}
.kodza-phone.is-complete .kodza-phone__done:not(#_) {
transition: stroke-dashoffset calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-phone-ease) calc(0.05s * var(--kz-motion-scale, 1));
}
.kodza-phone__search:not(#_) {
display: block;
box-sizing: border-box;
width: 100%;
height: 44px;
margin: 0;
padding: 0 14px 0 40px;
border: 0;
border-radius: 0;
outline: none;
background: transparent;
box-shadow: none;
color: inherit;
font: inherit;
font-size: 14px;
line-height: 44px;
letter-spacing: inherit;
caret-color: var(--kz-phone-fg);
appearance: none;
}
.kodza-phone__search:not(#_)::placeholder {
color: var(--kz-phone-muted);
opacity: 1;
}
.kodza-phone__list:not(#_) {
position: relative;
box-sizing: border-box;
max-height: var(--kz-phone-list-height);
margin: 0;
padding: 6px;
list-style: none;
}
.kodza-phone__highlight:not(#_) {
position: absolute;
border-radius: calc(8px * var(--kz-radius-scale, 1));
background: var(--kz-phone-soft);
}
.kodza-phone.is-open .kodza-phone__highlight:not(#_) {
transition: transform calc(0.22s * var(--kz-motion-scale, 1)) var(--kz-phone-ease),
opacity calc(0.15s * var(--kz-motion-scale, 1)) ease;
}
.kodza-phone__option:not(#_) {
position: relative;
display: flex;
margin: 0;
padding: 0 10px 0 32px;
border-radius: calc(8px * var(--kz-radius-scale, 1));
font-size: 14px;
font-weight: 500;
}
.kodza-phone__option[hidden]:not(#_),
.kodza-phone__empty[hidden]:not(#_) {
display: none;
}
.kodza-phone__option svg:not(#_) {
position: absolute;
transition: stroke-dashoffset calc(0.15s * var(--kz-motion-scale, 1)) ease-in;
}
.kodza-phone__option[aria-selected="true"] svg:not(#_) {
transition: stroke-dashoffset calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-phone-ease) calc(0.05s * var(--kz-motion-scale, 1));
}
.kodza-phone__empty:not(#_) {
margin: 0;
padding: 18px 10px;
color: var(--kz-phone-muted);
font-size: 13px;
text-align: center;
}
.kodza-phone__note:not(#_) {
display: grid;
margin: 0;
padding: 0 2px;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: var(--kz-font-label-leading, 1.4);
text-transform: var(--kz-font-label-case, inherit);
}
@media (prefers-reduced-motion: reduce) {
.kodza-phone__box:not(#_),
.kodza-phone__code svg:not(#_),
.kodza-phone__done:not(#_),
.kodza-phone.is-open .kodza-phone__highlight:not(#_),
.kodza-phone__option svg:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-phone__code:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Phone Input
// Puts the country code in front of the real tel input ("FR +33", in mono) and opens a list to
// find a country by name or code. The number groups itself as it is typed, the way that country
// writes it; a pasted or autofilled "+44 7700…" picks the country on its own, and so does typing
// "+" (or "00") then a code. Backspace over a space takes the digit behind it. The form gets the
// whole number in E.164 (+447700900123) under the input's name, from a hidden input; leaving the
// field says when the number is short or long for its country, and a tick draws itself once it
// has the right length. After setting .value from a script, fire "change" on the input.
// Settings: --kz-phone-* in the CSS; data-country (the country first shown, else the visitor's
// region), data-countries="GB,IE,FR" (the list and its order; with one country the code is a
// plain label), data-locale (the language of country names, else the page's). The words,
// timings and countries are below; each word can be set per field with data-text-<name>
// (data-text-short-one="…"), {country}, {dial}, {left}, {extra} and {count} are filled in.
(() => {
const SELECTOR = '.kodza-phone';
const WORDS = {
search: 'Country or code', // the search on top of the list
code: 'Country code: {country} +{dial}', // the code button, read out
empty: 'No country matches.',
count: '{count} countries', // read out as the list filters
one: '1 country',
short: '{left} more digits for {country}.',
shortOne: '1 more digit for {country}.',
long: '{extra} digits too many for {country}.',
longOne: '1 digit too many for {country}.',
unknown: 'No country here dials +{dial}.',
required: 'Enter a phone number.',
detected: '{country}, +{dial}', // read out when a typed or pasted code picks the country
};
const ROLL = 420; // ms, the code rolling to a new country
const RESIZE = 240; // ms, the list changing height as it filters
const ANNOUNCE = 400; // ms of quiet before the count is read out (a wait, not a motion)
const PAGE = 5; // rows per PageUp / PageDown
const LONGEST = 15; // digits after a "+" no country claims yet (E.164 caps a number at 15)
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
// One line per country: ISO code | dialing code | trunk prefix, what is dialled before a number
// at home (0 in France, 1 in the US, none in Spain) | an example number as the country writes
// it, which sets the grouping and the length | the fewest and the most digits a number takes,
// when they differ from the example's. A length check, not a carrier lookup: add a line, or a
// range, for a country that needs one. US before Canada: "+1" picks it unless Canada is chosen
const COUNTRIES = `
FR|33|0|6 12 34 56 78
GB|44|0|7400 123456
US|1|1|(201) 555-0123
CA|1|1|(506) 234-5678
DE|49|0|1512 3456789|10
BE|32|0|470 12 34 56
NL|31|0|6 12345678
LU|352||628 123 456
CH|41|0|78 123 45 67
AT|43|0|664 123456|7|13
ES|34||612 34 56 78
PT|351||912 345 678
IT|39||312 345 6789|9
IE|353|0|85 012 3456
DK|45||32 12 34 56
SE|46|0|70 123 45 67
NO|47||406 12 345
FI|358|0|41 2345678|9|10
IS|354||611 1234
PL|48||512 345 678
CZ|420||601 123 456
SK|421|0|912 123 456
HU|36|06|20 123 4567
RO|40|0|712 034 567
BG|359|0|87 123 4567|8
GR|30||691 234 5678
HR|385|0|92 123 4567|8
SI|386|0|31 234 567
RS|381|0|60 1234567|8
UA|380|0|50 123 4567
TR|90|0|501 234 56 78
RU|7|8|(912) 345-67-89
EE|372||5123 4567|7
LV|371||21 234 567
LT|370|8|612 34567
MT|356||9696 1234
CY|357||96 123456
IL|972|0|50 234 5678
AE|971|0|50 123 4567
SA|966|0|51 234 5678
QA|974||3312 3456
EG|20|0|100 123 4567
MA|212|0|650-123456
DZ|213|0|551 23 45 67
TN|216||20 123 456
SN|221||70 123 45 67
CI|225||01 23 45 6789
NG|234|0|802 123 4567
KE|254|0|712 123456
ZA|27|0|71 123 4567
IN|91|0|81234 56789
PK|92|0|301 2345678
BD|880|0|1812-345678
CN|86|0|131 2345 6789
HK|852||5123 4567
TW|886|0|912 345 678
JP|81|0|90-1234-5678
KR|82|0|10-2345-6789
SG|65||8123 4567
MY|60|0|12-345 6789|9|10
TH|66|0|81 234 5678
VN|84|0|91 234 56 78
PH|63|0|905 123 4567
ID|62|0|812-345-678|9|12
AU|61|0|412 345 678
NZ|64|0|21 123 4567|8|10
MX|52||222 123 4567
BR|55||(11) 96123-4567|10
AR|54|0|11 2345-6789|10|11
CL|56||9 6123 4567
CO|57||321 1234567
PE|51||912 345 678
`.trim().split('\n').map((line) => {
const [iso, dial, trunk, example, min, max] = line.trim().split('|');
const digits = example.replace(/\D/g, '');
const most = +max || digits.length;
return { iso, dial, trunk, pattern: example.replace(/\d/g, '#'), digits, min: +min || most, max: most };
});
const BY_ISO = new Map(COUNTRIES.map((country) => [country.iso, country]));
// The short names people type for a few of them
const ALIASES = { GB: 'uk', US: 'usa', AE: 'uae' };
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const tick = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" pathLength="1"/></svg>';
const chevron = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4"/></svg>';
let uid = 0;
// A national number grouped like the country's example; digits past the example run on
// ungrouped, and the trunk prefix goes in front when it was typed ("06 12 34 56 78")
const format = (digits, country, trunked) => {
let out = '';
let sep = '';
let i = 0;
for (const slot of country.pattern) {
if (i >= digits.length) break;
if (slot === '#') {
out += sep + digits[i];
sep = '';
i += 1;
} else sep += slot;
}
out += digits.slice(i);
if (trunked) out = country.trunk + (out && country.trunk !== '0' ? ' ' : '') + out;
return out;
};
// What counts in a phone number: its digits, and a "+" in front ("00" in front says the same)
const clean = (text) => {
let digits = text.replace(/[^\d+]/g, '');
const plus = digits.startsWith('+');
digits = digits.replace(/\+/g, '');
if (!plus && digits.startsWith('00')) return `+${digits.slice(2)}`;
return plus ? `+${digits}` : digits;
};
// Where the caret goes in a grouped number: right after its n-th digit (a "+" in front counts)
const place = (value, n) => {
if (n <= 0) return 0;
let seen = 0;
for (let i = 0; i < value.length; i += 1) {
if (/\d/.test(value[i]) || (i === 0 && value[i] === '+')) seen += 1;
if (seen === n) return i + 1;
}
return value.length;
};
// Text as it is searched: lower case, no accents, the typographic apostrophes as typed on a
// keyboard. `at` gives, for each folded character, its place in the original text
const fold = (value) => {
let text = '';
const at = [];
let i = 0;
for (const char of value) {
const folded = char.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/[‘’]/g, "'");
for (let n = 0; n < folded.length; n += 1) at.push(i);
text += folded;
i += char.length;
}
at.push(value.length);
return { text, at };
};
// Where the query starts a word ("kingdom" in "united kingdom"), or -1
const wordAt = (text, query) => {
for (let s = text.indexOf(query); s >= 0; s = text.indexOf(query, s + 1)) {
if (!s || !/[\p{L}\p{N}]/u.test(text[s - 1])) return s;
}
return -1;
};
const fill = (words, values) => words.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));
// The matched letters, underlined (a slice of the original text, accents and all)
const mark = (el, text, folded, start, length) => {
if (start < 0 || !length) return el.replaceChildren(text);
const from = folded.at[start];
const to = Math.max(folded.at[start + length], folded.at[start + length - 1] + 1);
const marked = document.createElement('mark');
marked.textContent = text.slice(from, to);
el.replaceChildren(text.slice(0, from), marked, text.slice(to));
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const box = root.querySelector('.kodza-phone__box');
const native = root.querySelector('.kodza-phone__native');
const label = root.querySelector('.kodza-phone__label');
const hint = root.querySelector('.kodza-phone__hint');
const error = root.querySelector('.kodza-phone__error');
if (!box || !native) return;
const id = `kodza-phone-${++uid}`;
const word = (key) => root.dataset[`text${key[0].toUpperCase()}${key.slice(1)}`] || WORDS[key];
// Country names in the page's language; the first country guessed from the visitor's own
const lang = root.dataset.locale || root.closest('[lang]')?.lang || navigator.language || 'en';
let names = null;
try {
names = new Intl.DisplayNames([lang, 'en'], { type: 'region' });
} catch {}
const named = new Map();
const nameOf = (country) => {
if (!named.has(country)) {
let name = country.iso;
try {
name = names?.of(country.iso) || name;
} catch {}
named.set(country, name);
}
return named.get(country);
};
const listed = (root.dataset.countries || '').split(',').map((iso) => BY_ISO.get(iso.trim().toUpperCase())).filter(Boolean);
const collator = new Intl.Collator(lang);
const allowed = listed.length ? [...new Set(listed)] : COUNTRIES.slice().sort((a, b) => collator.compare(nameOf(a), nameOf(b)));
let region = '';
try {
region = new Intl.Locale(navigator.language || lang).maximize().region || '';
} catch {}
const first = [root.dataset.country, region].map((iso) => BY_ISO.get((iso || '').toUpperCase())).find((country) => allowed.includes(country)) || allowed[0];
const fixed = allowed.length === 1;
// The code in front of the number: a button that opens the list, or a plain label for one
// country. Next to the box, not in it: a button inside a <label> is not valid HTML
const code = document.createElement(fixed ? 'span' : 'button');
code.className = 'kodza-phone__code';
code.id = `${id}-code`;
const faces = document.createElement('span');
faces.className = 'kodza-phone__faces';
code.append(faces);
if (fixed) code.classList.add('is-fixed');
else {
code.type = 'button';
code.setAttribute('aria-haspopup', 'listbox');
code.setAttribute('aria-expanded', 'false');
code.setAttribute('aria-controls', `${id}-list`);
code.insertAdjacentHTML('beforeend', chevron);
}
box.before(code);
// The number line: a hairline after the code, the tel input, and under it the rest of an
// example number in grey (the typed part kept invisible, so the rest lines up)
const sep = document.createElement('span');
sep.className = 'kodza-phone__sep';
sep.setAttribute('aria-hidden', 'true');
const field = document.createElement('span');
field.className = 'kodza-phone__field';
const example = document.createElement('span');
example.className = 'kodza-phone__example';
example.setAttribute('aria-hidden', 'true');
const typed = document.createElement('span');
typed.className = 'kodza-phone__typed';
const rest = document.createTextNode('');
example.append(typed, rest);
native.before(sep, field);
field.append(native, example);
box.insertAdjacentHTML('beforeend', `<svg class="kodza-phone__done" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" pathLength="1"/></svg>`);
if (label) {
label.id = `${id}-label`;
native.setAttribute('aria-labelledby', `${id}-label ${id}-code`);
}
const described = [];
if (hint) described.push((hint.id = `${id}-hint`));
if (error) described.push((error.id = `${id}-error`));
if (described.length) native.setAttribute('aria-describedby', described.join(' '));
// The form gets the whole number from a hidden input that takes the field's name
let hidden = null;
if (native.name) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = native.name;
native.removeAttribute('name');
root.append(hidden);
}
const live = document.createElement('span');
live.className = 'kodza-phone__live';
live.setAttribute('aria-live', 'polite');
root.append(live);
// The list: a search on top, one row per country (put in the page only when there is a choice)
const pop = document.createElement('div');
pop.className = 'kodza-phone__pop';
const find = document.createElement('div');
find.className = 'kodza-phone__find';
const search = document.createElement('input');
search.className = 'kodza-phone__search';
search.type = 'text';
search.autocomplete = 'off';
search.spellcheck = false;
search.placeholder = word('search');
search.setAttribute('aria-label', word('search'));
search.setAttribute('role', 'combobox');
search.setAttribute('aria-autocomplete', 'list');
search.setAttribute('aria-expanded', 'false');
search.setAttribute('aria-controls', `${id}-list`);
find.append(search);
const list = document.createElement('ul');
list.className = 'kodza-phone__list';
list.id = `${id}-list`;
list.tabIndex = -1;
list.setAttribute('role', 'listbox');
if (label) list.setAttribute('aria-labelledby', `${id}-label`);
const highlight = document.createElement('li');
highlight.className = 'kodza-phone__highlight';
highlight.setAttribute('role', 'presentation');
const empty = document.createElement('li');
empty.className = 'kodza-phone__empty';
empty.setAttribute('role', 'presentation');
empty.hidden = true;
empty.textContent = word('empty');
list.append(highlight, empty);
const rows = allowed.map((country, order) => {
const item = document.createElement('li');
item.className = 'kodza-phone__option';
item.id = `${id}-option-${order}`;
item.setAttribute('role', 'option');
item.innerHTML = tick;
const abbr = document.createElement('span');
abbr.className = 'kodza-phone__abbr';
abbr.setAttribute('aria-hidden', 'true');
const name = document.createElement('span');
name.className = 'kodza-phone__name';
const meta = document.createElement('span');
meta.className = 'kodza-phone__meta';
item.append(abbr, name, meta);
list.insertBefore(item, empty);
const text = nameOf(country);
return { country, item, abbr, name, meta, order, text, folded: fold(text), dialed: fold(`+${country.dial}`), iso: fold(country.iso) };
});
const entries = new Map(rows.map((entry) => [entry.item, entry]));
pop.append(find, list);
if (!fixed) code.after(pop);
const isOpen = () => root.classList.contains('is-open');
let active = null;
let hits = []; // the rows the search matches, best first, in the order of the list
// The list for a search: digits look for a code (+44, 35…), letters for a name or an ISO
// code; the best come first, the rest hide. The list changes height smoothly while open
let resize = null;
let announced = 0;
const render = (query, speak = true) => {
const raw = query.trim().replace(/^\+$/, '');
const digits = /^\+?[\d\s]+$/.test(raw) ? raw.replace(/\D/g, '') : '';
const q = fold(raw).text;
const still = reduceMotion || !paceOf(root);
const from = isOpen() && !still ? list.offsetHeight : 0;
resize?.cancel();
const found = [];
rows.forEach((entry) => {
const { country, text, folded } = entry;
let rank = -1;
let inName = -1;
let inIso = -1;
let inDial = -1;
if (!raw) rank = 0;
else if (digits) {
if (country.dial === digits) [rank, inDial] = [0, 1];
else if (country.dial.startsWith(digits)) [rank, inDial] = [1, 1];
} else if (q === entry.iso.text) [rank, inIso] = [0, 0];
else if (q === ALIASES[country.iso]) rank = 0;
else if (folded.text.startsWith(q)) [rank, inName] = [1, 0];
else if ((inName = wordAt(folded.text, q)) >= 0) rank = 2;
else if ((inName = folded.text.indexOf(q)) >= 0) rank = 3;
mark(entry.name, text, folded, inName, q.length);
mark(entry.abbr, country.iso, entry.iso, inIso, country.iso.length);
mark(entry.meta, `+${country.dial}`, entry.dialed, inDial, digits.length);
entry.item.hidden = rank < 0;
if (rank >= 0) found.push({ entry, rank });
});
found.sort((a, b) => a.rank - b.rank || a.entry.order - b.entry.order);
hits = found.map(({ entry }) => entry);
hits.forEach(({ item }) => list.insertBefore(item, empty));
rows.forEach(({ item }) => item.hidden && list.insertBefore(item, empty));
empty.hidden = hits.length > 0;
list.scrollTop = 0;
if (from) {
const to = list.offsetHeight;
if (Math.abs(to - from) > 0.5) {
list.style.overflowY = 'hidden';
resize = list.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: RESIZE * paceOf(root), easing: ease });
const done = () => {
list.style.overflowY = '';
resize = null;
};
resize.onfinish = done;
resize.oncancel = done;
}
}
// Read out how many are left, once the typing pauses
clearTimeout(announced);
const count = hits.length;
if (speak) announced = setTimeout(() => {
live.textContent = !count ? word('empty') : count === 1 ? word('one') : fill(word('count'), { count });
}, ANNOUNCE);
return hits[0] || null;
};
const light = (entry, scroll = true) => {
active = entry;
if (!entry) {
search.removeAttribute('aria-activedescendant');
return list.style.setProperty('--kz-hl-on', 0);
}
search.setAttribute('aria-activedescendant', entry.item.id);
list.style.setProperty('--kz-hl-y', `${entry.item.offsetTop}px`);
list.style.setProperty('--kz-hl-h', `${entry.item.offsetHeight}px`);
list.style.setProperty('--kz-hl-on', 1);
// Scroll the list only (scrollIntoView would also nudge the page), with the list's own
// padding above the very first row
if (scroll) {
const top = entry === hits[0] ? 0 : entry.item.offsetTop;
const bottom = entry.item.offsetTop + entry.item.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
}
};
// The state of the number: its country, its digits after the trunk prefix, and whether it is
// still a "+" no country claims (intl)
let country = null;
let national = '';
let trunked = false;
let intl = false;
let dialed = false; // the code came from the field ("+33"): the example leaves the trunk out
let complete = false;
let prev = '';
// The code rolls to a new country: the old face goes up, the new one comes from below, and
// the button eases to its new width (the number line follows its edge)
let face = null;
let rolls = [];
let sized = '';
const size = (animate) => {
if (!root.offsetWidth || !face) return; // not rendered yet: the ResizeObserver comes back
const scale = root.getBoundingClientRect().width / root.offsetWidth || 1; // a parent scaled by transform
const width = Math.ceil(face.getBoundingClientRect().width / scale - 0.01);
const extra = Math.round((code.getBoundingClientRect().width - faces.getBoundingClientRect().width) / scale);
const start = code.offsetLeft + extra + width;
if (sized === `${width} ${start}`) return;
sized = `${width} ${start}`;
const parts = [faces, sep, field];
const still = !animate || reduceMotion || !paceOf(root);
if (still) parts.forEach((el) => (el.style.transition = 'none'));
root.style.setProperty('--kz-phone-face', `${width}px`);
root.style.setProperty('--kz-phone-start', `${start}px`);
if (still) {
void root.offsetWidth;
parts.forEach((el) => (el.style.transition = ''));
}
};
const setCountry = (next, roll) => {
if (next === country) return;
country = next;
const old = face;
face = document.createElement('span');
face.className = 'kodza-phone__face';
const iso = document.createElement('span');
iso.className = 'kodza-phone__iso';
iso.textContent = next.iso;
face.append(iso, `+${next.dial}`);
rolls.forEach((animation) => animation.cancel());
rolls = [];
[...faces.children].forEach((el) => el !== old && el.remove());
faces.append(face);
if (!fixed) code.setAttribute('aria-label', fill(word('code'), { country: nameOf(next), dial: next.dial }));
rows.forEach(({ country: c, item }) => item.setAttribute('aria-selected', String(c === next)));
const pace = paceOf(root);
if (old && (!roll || reduceMotion || !pace)) old.remove();
else if (old) {
const timing = { duration: ROLL * pace, easing: ease };
const out = old.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' });
out.onfinish = () => old.remove();
rolls = [out, face.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing)];
}
size(roll);
};
// The country a number dialled from abroad belongs to: the allowed one whose code opens the
// digits. The country already shown wins a shared code (+1: US or Canada)
const detect = (digits) => {
for (let n = 1; n <= 3 && n <= digits.length; n += 1) {
const found = allowed.filter(({ dial }) => dial === digits.slice(0, n));
if (found.length) return found.includes(country) ? country : found[0];
}
return null;
};
// Reads the digits of the field into a country and a national number, and returns them
// grouped (end: the caret goes to the end). `how` is how they came: typed (one digit too
// many does nothing, null), pasted or filled in (cut to length), deleted
const take = (text, how, quiet = false) => {
if (text.startsWith('+')) {
const digits = text.slice(1);
const found = detect(digits);
if (!found) {
[intl, trunked, national] = [true, false, ''];
return { shown: `+${digits.slice(0, LONGEST)}` };
}
let number = digits.slice(found.dial.length);
// "+33 (0)6…": a trunk prefix written anyway, seen by the extra digit it adds
if (found.trunk && number.startsWith(found.trunk) && number.length > found.max) number = number.slice(found.trunk.length);
[intl, trunked, national, dialed] = [false, false, number.slice(0, found.max), true];
setCountry(found, !quiet);
if (!quiet) live.textContent = fill(word('detected'), { country: nameOf(found), dial: found.dial });
return { shown: format(national, found, false), end: true };
}
const hasTrunk = !!country.trunk && text.startsWith(country.trunk);
let number = hasTrunk ? text.slice(country.trunk.length) : text;
if (number.length > country.max) {
if (how === 'type' && number.length > national.length) return null;
if (how === 'paste') number = number.slice(0, country.max);
}
[intl, trunked, national] = [false, hasTrunk, number];
return { shown: format(number, country, hasTrunk) };
};
const show = (value, n = -1) => {
if (native.value !== value) native.value = value;
prev = value;
if (document.activeElement !== native) return;
const at = n < 0 ? value.length : place(value, n);
native.setSelectionRange(at, at);
};
// What the browser checks: a "+" no country here claims, too few digits, too many
const problem = () => {
if (intl) {
const digits = native.value.slice(1);
if (!digits) return native.required ? word('required') : '';
const known = COUNTRIES.find(({ dial }) => digits.startsWith(dial));
return fill(word('unknown'), { dial: known ? known.dial : digits.slice(0, 3) });
}
const values = { country: nameOf(country) };
if (!national) return trunked ? fill(word('short'), { ...values, left: country.min }) : '';
const left = country.min - national.length;
if (left > 0) return fill(word(left === 1 ? 'shortOne' : 'short'), { ...values, left });
const extra = national.length - country.max;
if (extra > 0) return fill(word(extra === 1 ? 'longOne' : 'long'), { ...values, extra });
return '';
};
// The rest of the example, in grey after what is typed, while the field has the focus and
// the number is not whole yet. An empty field shows a trunk 0, the way people write it at home
const hintNow = () => {
const on = document.activeElement === native && !intl && !complete;
if (on) {
const withTrunk = native.value ? trunked : /^0/.test(country.trunk) && !dialed;
typed.textContent = native.value;
rest.data = format(country.digits, country, withTrunk).slice(native.value.length);
}
root.classList.toggle('is-hinting', on && !!rest.data && native.scrollWidth <= native.clientWidth);
};
// After any change: what the form gets, what the browser checks, the tick, the example
const update = () => {
if (hidden) hidden.value = !intl && national ? `+${country.dial}${national}` : '';
complete = !intl && national.length >= country.min && national.length <= country.max;
root.classList.toggle('is-complete', complete);
native.setCustomValidity(problem());
hintNow();
};
const load = (value) => {
show(take(clean(value), 'paste', true).shown);
update();
};
// Hint and error share the line under the field
const say = (text) => {
if (!error) return;
error.textContent = text;
root.classList.add('is-invalid');
native.setAttribute('aria-invalid', 'true');
};
const clearError = () => {
root.classList.remove('is-invalid');
native.removeAttribute('aria-invalid');
};
const toNumber = () => {
native.focus({ preventScroll: true });
const end = native.value.length;
native.setSelectionRange(end, end);
};
const open = () => {
if (fixed || isOpen() || native.matches(':disabled')) return;
search.value = '';
render('', false);
// Not enough room below and more above: the list opens upwards. A closed list that changes
// side gets there at once, so it always unrolls from the field
const rect = box.getBoundingClientRect();
const above = rect.bottom + pop.offsetHeight + 16 > innerHeight && rect.top > innerHeight / 2;
if (above !== root.classList.contains('is-above')) {
pop.style.transition = 'none';
root.classList.toggle('is-above', above);
void pop.offsetWidth;
pop.style.transition = '';
}
// Lit and centred on the current country; measuring settles the highlight there before the
// open list starts animating it
const current = rows.find((entry) => entry.country === country);
light(current, false);
if (current) list.scrollTop = current.item.offsetTop - (list.clientHeight - current.item.offsetHeight) / 2;
root.classList.add('is-open');
code.setAttribute('aria-expanded', 'true');
search.setAttribute('aria-expanded', 'true');
search.focus({ preventScroll: true });
};
const close = (back) => {
resize?.cancel();
clearTimeout(announced);
if (!isOpen()) return;
root.classList.remove('is-open');
code.setAttribute('aria-expanded', 'false');
search.setAttribute('aria-expanded', 'false');
search.removeAttribute('aria-activedescendant');
back?.focus({ preventScroll: true });
};
// A country from the list: the number keeps its digits, grouped the new way (the trunk
// prefix belonged to the old country, a "+" in progress goes)
const pick = (entry, focus = true) => {
if (!entry) return;
close();
if (entry.country !== country || intl) {
const before = hidden?.value;
if (intl) national = '';
// A trunk 0 typed for France still reads as one in Germany
const keep = !intl && trunked && entry.country.trunk === country.trunk;
[intl, trunked, dialed] = [false, keep, false];
setCountry(entry.country, true);
clearError();
show(format(national, country, trunked));
update();
if (hidden && hidden.value !== before) hidden.dispatchEvent(new Event('change', { bubbles: true }));
}
if (focus) toNumber();
};
// Typing: the digits are read again and grouped, the caret kept after the same digit
native.addEventListener('input', (event) => {
const raw = native.value;
const type = event.inputType || '';
let text = clean(raw);
let n = clean(raw.slice(0, native.selectionStart ?? raw.length)).length;
// Only a space or a dash went: the digit behind it goes with it
if (type.startsWith('deleteContent') && text && text === clean(prev)) {
const at = type === 'deleteContentBackward' ? n - 1 : n;
if (at >= 0 && at < text.length) {
text = text.slice(0, at) + text.slice(at + 1);
n = at;
}
}
const how = type === 'insertText' ? 'type' : type.startsWith('delete') ? 'delete' : 'paste';
const result = take(text, how);
clearError();
if (!result) return show(prev, n - 1);
if (!text) dialed = false;
show(result.shown, result.end ? -1 : n);
update();
}, { signal });
native.addEventListener('focus', hintNow, { signal });
native.addEventListener('blur', hintNow, { signal });
// Set from a script: read again
native.addEventListener('change', () => native.value !== prev && load(native.value), { signal });
// Leaving the field says what is wrong with the number; "required" waits for the form
root.addEventListener('focusout', (event) => {
if (root.contains(event.relatedTarget) || document.activeElement === event.target) return;
if (native.value && native.validationMessage) say(native.validationMessage);
}, { signal });
native.addEventListener('invalid', (event) => {
event.preventDefault();
say(native.validity.customError ? native.validationMessage : word('required'));
const firstInvalid = native.form && [...native.form.elements].find((el) => el.willValidate && !el.validity.valid);
if (!native.form || firstInvalid === native) native.focus();
}, { signal });
// The code button: a click opens the list (or closes it, back to the number), the keys too
code.addEventListener('mousedown', (event) => isOpen() && event.preventDefault(), { signal });
code.addEventListener('click', () => {
if (!isOpen()) return open();
close();
toNumber();
}, { signal });
code.addEventListener('keydown', (event) => {
if (![' ', 'Enter', 'ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
open();
}, { signal });
search.addEventListener('input', () => light(render(search.value)), { signal });
search.addEventListener('keydown', (event) => {
if (event.isComposing) return;
const moves = { ArrowDown: 1, ArrowUp: -1, PageDown: PAGE, PageUp: -PAGE };
if (event.key in moves) {
event.preventDefault();
if (!hits.length) return;
const index = hits.indexOf(active);
const step = moves[event.key];
const next = index < 0 ? (step > 0 ? 0 : hits.length - 1) : Math.max(0, Math.min(hits.length - 1, index + step));
light(hits[next]);
} else if (event.key === 'Enter') {
event.preventDefault();
pick(active);
} else if (event.key === 'Tab' && active) {
pick(active, false);
} else if (event.key === 'Escape') {
event.preventDefault();
close(code);
}
}, { signal });
search.addEventListener('blur', (event) => {
if (document.activeElement === search) return; // the window lost the focus, not the search
if (!pop.contains(event.relatedTarget)) close();
}, { signal });
const entryAt = (event) => entries.get(event.target.closest?.('.kodza-phone__option'));
list.addEventListener('pointermove', (event) => {
const entry = entryAt(event);
if (entry && active !== entry) light(entry, false);
}, { signal });
list.addEventListener('click', (event) => pick(entryAt(event)), { signal });
// A click in the list keeps the focus in the search
pop.addEventListener('mousedown', (event) => event.target !== search && event.preventDefault(), { signal });
native.form?.addEventListener('reset', () => setTimeout(() => {
if (signal.aborted) return;
clearError();
[national, trunked, intl, dialed] = ['', false, false, false];
setCountry(first, false);
load(native.value);
}), { signal });
// Disabled or required by the page: the code and the check follow
const sync = () => {
const off = native.matches(':disabled');
if (!fixed) code.disabled = off;
root.classList.toggle('is-disabled', off);
if (off) close();
update();
};
const observer = new MutationObserver(sync);
observer.observe(native, { attributes: true, attributeFilter: ['disabled', 'required'] });
// Measured again once shown (a closed tab, a modal) and once the fonts are in
const watch = new ResizeObserver(() => size(false));
watch.observe(root);
document.fonts?.addEventListener('loadingdone', () => size(false), { signal });
signal.addEventListener('abort', () => {
observer.disconnect();
watch.disconnect();
clearTimeout(announced);
resize?.cancel();
rolls.forEach((animation) => animation.cancel());
});
// The value as written: "+44 7700 900123" picks its country at once
setCountry(first, false);
render('', false);
load(native.value);
sync();
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Phone Input, for React. From https://kodza.dev/components/phone-input/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: phone-input.css and phone-input.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './phone-input.css';
export type PhoneInputProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "country" | "countries"> & {
/** data-country on the root (default: FR) */
country?: "FR" | "GB";
/** data-countries on the root */
countries?: "GB,IE,FR,BE,NL,DE,ES,IT,PT" | "US";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-phone-bg"?: string | number;
/** default: #0a0a0a */
"--kz-phone-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-phone-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-phone-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-phone-line-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-phone-soft"?: string | number;
/** a message, never colour alone: set a red here if you want one — default: #0a0a0a */
"--kz-phone-error"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-phone-shadow"?: string | number;
/** default: 56px */
"--kz-phone-height"?: string | number;
/** default: calc(12px * var(--kz-radius-scale, 1)) */
"--kz-phone-radius"?: string | number;
/** default: 16px */
"--kz-phone-pad"?: string | number;
/** default: var(--kz-field-width, 320px) */
"--kz-phone-width"?: string | number;
/** the open list of countries, at most: it scrolls past that — default: 236px */
"--kz-phone-list-height"?: string | number;
/** default: 0.35s */
"--kz-phone-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-phone-ease"?: string | number;
};
className?: string;
};
export function PhoneInput({ country = "FR", countries, vars, className, style, ...rest }: PhoneInputProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-phone", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, country, countries, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./phone-input.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<div key={key} {...rest} className={classes} data-kodza-manual data-country={country} data-countries={countries} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<label className="kodza-phone__box">
<span className="kodza-phone__label">Mobile</span>
{' '}
<input className="kodza-phone__native" type="tel" name="mobile" autoComplete="tel" required />
</label>
<p className="kodza-phone__note">
<span className="kodza-phone__hint">We text one code. Not a newsletter.</span>
{' '}
<span className="kodza-phone__error" aria-live="polite" />
</p>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.