Input Mask
stimeo--input-mask
Shapes what you type into a fixed pattern, leaving the caret exactly where you put it.
Shapes a fixed-format entry such as a phone or postal number as you type. Brackets and dashes are added for you, and a character that cannot go in that position is not accepted. The caret stays put: a rejected keystroke leaves it where it was, and deleting across a separator removes one character at a time rather than standing still. Text being converted by a Japanese input method is left alone, and full-width digits are taken as their plain form only where that position accepts them. The screen shows the shaped value while your server receives it without the punctuation. A read-only field keeps whatever the page rendered.
Raw value sent to the server:
(empty)
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the input mask demo.
The controller formats the field in place against the pattern (9=digit), inserts
the separators, rejects non-digits, preserves the caret, and syncs the raw value
to the hidden field. demo.js mirrors that hidden raw value (and the complete flag)
into the visible readout below, since the unmask field is hidden. %>
<div class="mask-demo">
<form class="mask-demo__form">
<label class="mask-demo__field">
<span><%= t("components.input_mask.demo.label") %></span>
<input type="text" class="demo-input" inputmode="numeric"
data-controller="stimeo--input-mask"
data-stimeo--input-mask-pattern-value="(999) 999-9999"
data-action="input->stimeo--input-mask#format"
aria-describedby="mask-hint"
placeholder="<%= t("components.input_mask.demo.placeholder") %>">
<input type="hidden" name="phone" data-stimeo--input-mask-unmask>
</label>
<p id="mask-hint" class="mask-demo__hint"><%= t("components.input_mask.demo.hint") %></p>
</form>
<p class="mask-demo__raw"
data-empty="<%= t("components.input_mask.demo.empty") %>"
data-complete="<%= t("components.input_mask.demo.complete") %>">
<%= t("components.input_mask.demo.raw_label") %>
<code data-mask-demo="raw"><%= t("components.input_mask.demo.empty") %></code>
<span data-mask-demo="status"></span>
</p>
</div>
/*
* Presentation-only styles for the input mask demo.
* The library formats the value, preserves the caret, and reflects
* data-mask-complete / data-mask-empty; this CSS owns layout and the readout, and
* can react to the completion flag for a finished-state affordance.
*/
.mask-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.mask-demo__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: 600;
}
/* The library marks a fully-filled mask with data-mask-complete. */
.mask-demo .demo-input[data-mask-complete] {
border-color: var(--leaf-500);
}
.mask-demo__hint {
margin: 0.25rem 0 0;
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-muted);
}
.mask-demo__raw {
margin: 0;
font-size: 0.9rem;
}
.mask-demo__raw code {
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
background: var(--surface-subtle);
font-family: ui-monospace, monospace;
}
// Consumer-side JS for the input mask demo (demo-only).
// The raw (unmasked) value the controller syncs lives in a hidden field, so this
// mirrors it into a visible readout from the change event, and shows a "complete"
// note when every slot is filled. Localized strings come from data attributes.
const readout = document.querySelector(".mask-demo__raw");
const raw = document.querySelector("[data-mask-demo='raw']");
const status = document.querySelector("[data-mask-demo='status']");
if (readout && raw) {
const render = (event) => {
const { unmasked, complete } = event.detail;
raw.textContent = unmasked || readout.dataset.empty;
if (status) status.textContent = complete ? ` ${readout.dataset.complete}` : "";
};
// change carries your edits; reconcile carries a value the controller decided
// (a server-rendered value normalized on connect), and both share the detail.
document.addEventListener("stimeo--input-mask:change", render);
document.addEventListener("stimeo--input-mask:reconcile", render);
}
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--input-mask"
Values
| Name | Description | Attribute |
|---|---|---|
pattern
|
The mask pattern (9 / a / * tokens and literals; default empty). |
data-stimeo--input-mask-pattern-value |
tokens
|
Placeholder → regex-source map as a JSON string; your keys merge over the defaults, and an unreadable declaration falls back to them. | data-stimeo--input-mask-tokens-value |
unmaskToHidden
|
Whether to sync the raw value to the hidden field (default true). Turning it off clears the sink it was keeping. |
data-stimeo--input-mask-unmask-to-hidden-value |
Actions
| Name | Description | Action |
|---|---|---|
format
|
Formats the field, preserving the caret; bound to the input event. |
stimeo--input-mask#format |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when your edit moves the value; detail carries masked, unmasked, complete. |
stimeo--input-mask:change |
reconcile
|
Fires when the controller decides the value itself — normalizing on connect, or after pattern / tokens / unmaskToHidden changed. Same detail as change. |
stimeo--input-mask:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-mask-complete |
The masked input | "true" once every token slot in the pattern is filled. |
data-mask-empty |
The masked input | "true" while the value is empty (e.g. for placeholder styling). |