Masks
Formats as you type, verifies the check digit of documents and hides sensitive data.
It doesn't replace the field with another control: it's behavior on top of your <input>, which keeps its
name and the styling the project already applies.
CPF is the Brazilian individual taxpayer ID, and the amount is in Brazilian reais. Type an invalid CPF and leave the field: the browser starts blocking the submit.
Examples
It's all the same data-tuc-mask with another format. The placeholder text comes from the pattern — nobody writes 000.000.000-00 by hand. The named formats follow Brazilian documents and conventions; the free pattern works for anything else.
CNPJ
The Brazilian company tax ID. Accepts the new format, with letters.
CPF or CNPJ
Switches pattern by length.
Phone
Brazilian landline with 8 digits or mobile with 9.
Mobile
Always with 9 digits.
CEP
cep, the Brazilian postal code.
Card
card, in groups of four.
Date and time, as text
date and time — just the mask, no calendar.
Currency without a symbol
currency, and three decimal places with data-decimals="3".
Another currency
data-currency="USD" on top of currency.
Free pattern
data-tuc-mask="##/##", card expiry date.
Pattern with letters
AAA-####: A is a letter, # is a digit. A free pattern doesn't convert to uppercase.
With a server error
aria-invalid="true" on the field.
Invalid CNPJ.
How to use
Mark the <input> with the format, and it initializes by itself on load and on every
htmx:afterSwap. The component puts the .tuc-input class on the field and sets a numeric inputmode
when the pattern doesn't accept letters, so phones open the number keypad.
<input name="cpf" data-tuc-mask="cpf" data-validate="true">
<input name="document" data-tuc-mask="cpf-cnpj">
<input name="phone" data-tuc-mask="phone">
<input name="amount" data-tuc-mask="brl">
<input name="expiry" data-tuc-mask="##/##">| Format | Result |
|---|---|
cpf | 111.444.777-35 |
cnpj | 11.222.333/0001-81 — accepts letters in the first 12 positions |
cnpj-numeric | The same, rejecting letters |
cpf-cnpj | One or the other, by length |
phone | (69) 3344-5566 or (69) 99988-7766 |
mobile | (69) 99988-7766 |
cep | 69900-000 |
date time | 25/12/2026 · 14:30 |
card | 4111 1111 1111 1111 |
real | R$ 1.234,50 |
currency | 1.234,50, no symbol — with data-currency, that currency's symbol |
| free pattern | # digit, A letter, * either; everything else goes in as is |
| Attribute | Default | What for |
|---|---|---|
data-tuc-mask | — | Format name or free pattern |
data-validate | false | Verifies the check digit when leaving the field |
data-error-text | from the format | Browser message when validation fails |
data-decimals | 2 | Decimal places for currency |
data-currency | — | Currency code, such as BRL or USD |
data-tuc-reveal | — | Show and hide eye; the value picks the mode |
data-reveal-visible | 2 | Characters left visible in end mode |
data-reveal-mode | from the field | end, email or all |
In the Django form
class CustomerForm(forms.ModelForm):
class Meta:
model = Customer
fields = ["document", "phone", "credit_limit"]
widgets = {
"document": forms.TextInput(attrs={"data-tuc-mask": "cpf-cnpj", "data-validate": "true"}),
"phone": forms.TextInput(attrs={"data-tuc-mask": "phone"}),
"credit_limit": forms.TextInput(attrs={"data-tuc-mask": "brl"}),
}The field posts the text as it appears on screen: 111.444.777-35, R$ 1.234,50. Clean it in clean_ —
browser validation protects the screen, not the server.
def clean_document(self):
return re.sub(r"[^0-9A-Z]", "", self.cleaned_data["document"].upper())
def clean_credit_limit(self):
text = re.sub(r"[^\d,]", "", self.cleaned_data["credit_limit"])
return Decimal(text.replace(",", ".")) if text else NoneIn JavaScript
const m = new Tucano.Mask('#amount', { format: 'brl' });
m.getRaw(); // '123450' — no punctuation
m.getNumber(); // 1234.5 — currency formats only; null for the others
m.setValue('99'); // formats and fires tucano:change
m.isValid(); // verifies the check digit, on formats that have one
m.destroy();
document.querySelector('#amount').addEventListener('tucano:change', (e) => {
e.detail.value; // 'R$ 1.234,50'
e.detail.raw; // '123450'
e.detail.number; // 1234.5
});The utilities behind the mask are exposed too, to validate or format outside a field:
Tucano.mask.validateCPF('111.444.777-35') // true
Tucano.mask.validateCNPJ('11.222.333/0001-81') // true
Tucano.mask.validateCpfCnpj('123.456.789-01') // false
Tucano.mask.format('12345678901', 'cpf') // '123.456.789-01'
Tucano.mask.applyCurrency('12345', { currency: 'BRL' }) // 'R$ 123,45'
Tucano.mask.maskEmail('contato@empresa.com.br') // 'c••••••@empresa.com.br'Validation
With data-validate, the check digit is verified when leaving the field. If it fails, the field gets
aria-invalid="true" and setCustomValidity, and the browser itself blocks the submit — without a line in the project.
And a correct value shows right away: as soon as the value is complete and right, the field turns green, without waiting
for it to lose focus. The error waits — while typing, a half-typed CPF never turns red; it is only flagged when leaving the
field, and clears again once the person goes back to fix it. The green state is the data-tuc-valid attribute,
painted with --tuc-success.
It applies to cpf, cnpj, cnpj-numeric and cpf-cnpj; the other formats have no digit
to verify. An empty field passes: requiredness belongs to required. While typing the error goes away, and when coming back to the field
too — including the flag that came from the server, because the person is fixing it.
<input name="cpf" data-tuc-mask="cpf" data-validate="true" data-error-text="Check the CPF">Alphanumeric CNPJ
The new format keeps the 14 positions and the same mask: the first 12 accept letters, the last 2 stay numeric, and the check digit uses the ASCII code minus 48 — which keeps the old calculation valid for all-digit CNPJs. cnpj accepts both and converts letters to uppercase; cnpj-numeric rejects letters, for those who can't receive them yet. Confirm the effective date in the Brazilian Federal Revenue's Technical Note before requiring it in production.
Sensitive field
data-tuc-reveal adds an eye to show and hide. A field that arrives already filled starts hidden,
with only the end visible; an empty field starts visible, because whoever is typing needs to see. Passwords are the exception: they always start hidden.
CPF from a record
end mode, with two visible.
Card
data-reveal-visible="4" keeps the last four.
On type="email", keeps the domain.
API key
data-tuc-reveal="all" shows nothing.
Password
On type="password" the eye only switches the type.
Empty
Starts visible; the eye hides it after typing.
| Mode | When | Result |
|---|---|---|
end, the default | Document, card, account, phone | •••• •••• •••• 1234 |
email | Automatic on type="email" | j•••••••••@empresa.com.br |
all | Token, API key | •••••••••••••••••••• |
<input name="cpf" value="111.444.777-35" data-tuc-mask="cpf" data-tuc-reveal>
<input name="card" data-tuc-mask="card" data-tuc-reveal data-reveal-visible="4">
<input name="api_key" data-tuc-reveal="all">
<input type="email" name="email" data-tuc-reveal>
<input type="password" name="password" data-tuc-reveal>What the form posts doesn't change because it's hidden. Except for type="password", the name moves to an
<input type="hidden"> with the real value, and the visible field shows the dots. While hidden, the field is
read-only: typing over the dots would overwrite the value without the person noticing. The separators stay in place,
so the shape remains recognizable: •••.•••.•••-35.
With a mask, the hidden field carries the value without punctuation
In a field with data-tuc-mask and data-tuc-reveal, what reaches the server is 11144477735, not 111.444.777-35 — it's getRaw(). Without a mask, the text goes as it was typed.
Why start hidden, and why email is the other way around
Screenshots, support recordings, someone looking over your shoulder: the full data showed up without anyone asking. Starting hidden is the difference between exposing by default and exposing by choice. In an email the domain is what helps recognize the account and the local part is what identifies the person — keeping the end would reveal om.br and hide the useful part. Without an @, it hides everything.
Loose text on the page
Not all sensitive data lives in a field: the CPF on a customer screen, the card number in a table cell, the API key in a
paragraph. data-tuc-reveal works on any element, in the same modes, and together with
data-tuc-format. The text starts hidden, with the eye next to it.
| Customer | CPF | Card | |
|---|---|---|---|
| John Smith | 11144477735 | 4111 1111 1111 1234 | john.smith@company.com |
API key: sk_live_a1b2c3d4e5f6
<td><span data-tuc-format="cpf" data-tuc-reveal>{{ customer.cpf }}</span></td>
<td><span data-tuc-reveal data-reveal-visible="4">{{ card.number }}</span></td>
<p>API key: <span data-tuc-reveal="all">{{ api_key }}</span></p>On the page, hiding is visual only
The full value is still in the HTML: anyone who opens the page source sees it. It protects against screenshots, recordings and someone looking over your shoulder. If the data must not reach the browser, hide it on the server and send only what may be shown, such as the card's last four digits.
Display only
For what already comes from the database and isn't edited, you don't need a field: data-tuc-format formats the text of the
element itself.
| Markup | On screen |
|---|---|
data-tuc-format="cpf" · 12345678901 | 12345678901 |
data-tuc-format="cnpj" · 11222333000181 | 11222333000181 |
data-tuc-format="cnpj" · 12ABC34501DE35 | 12ABC34501DE35 |
data-tuc-format="phone" · 69999887766 | 69999887766 |
data-tuc-format="cep" · 69900000 | 69900000 |
data-tuc-format="brl" · 1234.5 | 1234.5 |
data-tuc-format="currency" data-decimals="3" · 12.5 | 12.5 |
<span data-tuc-format="cpf">{{ customer.cpf }}</span>
<span data-tuc-format="brl">{{ order.total }}</span>
<span data-tuc-format="phone" data-value="{{ customer.phone }}"></span>Accepts cpf, cnpj, cpf-cnpj, phone, mobile, cep,
card, real, currency and free patterns, with data-decimals and data-currency
for currency. The value comes from the element's text or from data-value. Currency reads the database's decimal point
(1234.5) and also a comma. Content that doesn't fill the pattern comes back as it was, instead of coming out half done
and looking corrupted. For HTML inserted by some other path than HTMX, use Tucano.autoFormat(element).
Keyboard and accessibility
The mask uses the browser's keyboard handling, with no shortcuts of its own. What it takes care of is keeping the cursor from getting lost.
| Key | Action |
|---|---|
Backspace Delete | Over a separator, deletes the neighboring character, instead of getting stuck |
| Paste | Discards what doesn't fit: abc111x444.777--35 becomes 111.444.777-35 |
| Typing in currency | Fills from right to left, with the cursor always at the end |
Tab to the eye | It's a <button>: Enter and Space toggle |
The eye announces "Show" or "Hide" and its state through aria-pressed. When showing, focus goes to the field. When it fails
validation, the field gets aria-invalid="true", which the screen reader reads as invalid.
API
Generated from the code on every build — if something is not here, it does not exist.
[data-tuc-mask], [data-tuc-reveal] [data-tuc-format]:not([data-tuc-formatted])new Tucano.Mask(alvo, opcoes) Tucano.autoFormat()data-currency data-decimals data-error-text data-reveal-mode data-reveal-visible data-tuc-format data-tuc-mask data-tuc-reveal data-validate data-valuegetRaw getNumber setValue isValid destroytucano:changeOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
format | null | nome de FORMATS ou gabarito livre |
validate | false | valida no blur e bloqueia o submit |
decimals | 2 | |
currency | null | 'BRL' formata com R$ |
reveal | false | olhinho para mostrar e ocultar |
revealVisible | 2 | quantos caracteres ficam a mostra no modo 'end' |
revealMode | null | 'end' | 'email' | 'all'. null decide pelo campo |
locale | undefined | |
errorText | null | |
onChange | null |