Como criar um componente reutilizável de input de CPF para design systems

Aprenda a criar um componente reutilizável de input de CPF para design systems, com máscara, validação, temas e integração com API.

Redação CPFHub.io
Redação CPFHub.io
··11 min de leitura
Como criar um componente reutilizável de input de CPF para design systems

Criar um componente reutilizável de input de CPF para design systems garante que todos os produtos da empresa usem a mesma implementação — com máscara, validação local de dígitos verificadores e integração opcional com a API da CPFHub.io para consulta em tempo real. O resultado é consistência visual e técnica entre apps mobile, painéis web e totens de atendimento, sem reescrever a mesma lógica em cada projeto.

Introdução

Design systems são a espinha dorsal de produtos digitais escaláveis. Quando sua empresa possui múltiplos produtos -- um app mobile, um painel web, um totem de atendimento -- todos precisam de um campo de CPF que funcione de forma consistente, acessível e visualmente coerente.

Criar um componente de input de CPF reutilizável para seu design system garante que todos os times usem a mesma implementação, com as mesmas regras de validação, a mesma máscara e a mesma integração com a API da CPFHub.io, eliminando retrabalho e inconsistências entre produtos.

Requisitos do componente

Antes de começar a codificar, vamos definir os requisitos que um componente de CPF de design system deve atender:

Funcionais

  • Máscara automática no formato 000.000.000-00.
  • Validação de dígitos verificadores.
  • Suporte a consulta via API (opcional).
  • Callback onValidate e onLookup para comunicação com o formulário pai.

Não funcionais

  • Acessível (WCAG 2.1 AA).
  • Estilizável via tokens de design / CSS variables.
  • Compatível com bibliotecas de formulário (React Hook Form, Formik).
  • Documentado com exemplos de uso.

Definindo a interface TypeScript

A tipagem clara é fundamental para componentes de design system, pois facilita a adoção por outros desenvolvedores.

// types.ts
export type CPFInputStatus = 'idle' | 'typing' | 'validating' | 'valid' | 'invalid' | 'loading' | 'error';

export interface CPFLookupResult {
    cpf: string;
    name: string;
    nameUpper: string;
    gender: string;
    birthDate: string;
    day: string;
    month: string;
    year: string;
}

export interface CPFInputProps {
    /** Valor controlado do input (com máscara) */
    value?: string;
    /** Callback quando o valor muda */
    onChange?: (maskedValue: string, rawDigits: string) => void;
    /** Callback quando a validação local ocorre */
    onValidate?: (isValid: boolean, digits: string) => void;
    /** Callback quando a consulta à API retorna */
    onLookup?: (result: CPFLookupResult | null, error?: string) => void;
    /** Chave da API para consulta (se não fornecida, apenas validação local) */
    apiKey?: string;
    /** Timeout para consulta em ms (padrão: 10000) */
    timeout?: number;
    /** Habilitar consulta automática ao completar 11 dígitos */
    autoLookup?: boolean;
    /** Label do campo */
    label?: string;
    /** Texto de ajuda */
    helpText?: string;
    /** Tamanho do componente */
    size?: 'sm' | 'md' | 'lg';
    /** Desabilitar o campo */
    disabled?: boolean;
    /** Campo obrigatório */
    required?: boolean;
    /** ID para acessibilidade */
    id?: string;
    /** Classes CSS adicionais */
    className?: string;
}

Implementando o componente

Com a interface definida, vamos implementar o componente.

// CPFInput.tsx
import React, { useState, useCallback, useRef, useEffect, forwardRef } from 'react';
import type { CPFInputProps, CPFInputStatus } from './types';
import styles from './CPFInput.module.css';

function maskCPF(value: string): string {
    const digits = value.replace(/\D/g, '').slice(0, 11);
    let result = '';
    for (let i = 0; i < digits.length; i++) {
    if (i === 3 || i === 6) result += '.';
    if (i === 9) result += '-';
    result += digits[i];
    }
    return result;
}

function validateCPFDigits(cpf: string): boolean {
    if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false;
    let sum = 0;
    for (let i = 0; i < 9; i++) sum += parseInt(cpf[i]) * (10 - i);
    let remainder = (sum * 10) % 11;
    if (remainder === 10) remainder = 0;
    if (remainder !== parseInt(cpf[9])) return false;
    sum = 0;
    for (let i = 0; i < 10; i++) sum += parseInt(cpf[i]) * (11 - i);
    remainder = (sum * 10) % 11;
    if (remainder === 10) remainder = 0;
    return remainder === parseInt(cpf[10]);
}

export const CPFInput = forwardRef<HTMLInputElement, CPFInputProps>(({
    value: controlledValue,
    onChange,
    onValidate,
    onLookup,
    apiKey,
    timeout = 10000,
    autoLookup = false,
    label = 'CPF',
    helpText,
    size = 'md',
    disabled = false,
    required = false,
    id = 'cpf-input',
    className = ''
}, ref) => {
    const [internalValue, setInternalValue] = useState('');
    const [status, setStatus] = useState<CPFInputStatus>('idle');
    const [message, setMessage] = useState('');
    const controllerRef = useRef<AbortController | null>(null);

    const displayValue = controlledValue !== undefined ? controlledValue : internalValue;

    const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const masked = maskCPF(e.target.value);
    const digits = masked.replace(/\D/g, '');

    if (controlledValue === undefined) {
    setInternalValue(masked);
    }
    onChange?.(masked, digits);

    if (digits.length < 11) {
    setStatus('typing');
    setMessage('');
    return;
    }

    const isValid = validateCPFDigits(digits);
    onValidate?.(isValid, digits);

    if (!isValid) {
    setStatus('invalid');
    setMessage('CPF invalido. Verifique os digitos.');
    return;
    }

    setStatus('valid');
    setMessage('');

    if (autoLookup && apiKey) {
    performLookup(digits);
    }
    }, [controlledValue, onChange, onValidate, autoLookup, apiKey]);

    async function performLookup(digits: string) {
    setStatus('loading');
    setMessage('Consultando...');

    if (controllerRef.current) controllerRef.current.abort();
    controllerRef.current = new AbortController();
    const timeoutId = setTimeout(() => controllerRef.current?.abort(), timeout);

    try {
    const res = await fetch(`https://api.cpfhub.io/cpf/${digits}`, {
    headers: {
    'x-api-key': apiKey!,
    'Accept': 'application/json'
    },
    signal: controllerRef.current.signal
    });
    clearTimeout(timeoutId);
    const json = await res.json();

    if (json.success) {
    setStatus('valid');
    setMessage(`${json.data.name}`);
    onLookup?.(json.data);
    } else {
    setStatus('error');
    setMessage('CPF nao encontrado.');
    onLookup?.(null, 'not_found');
    }
    } catch (err: any) {
    clearTimeout(timeoutId);
    setStatus('error');
    const errorMsg = err.name === 'AbortError' ? 'Tempo esgotado.' : 'Erro na consulta.';
    setMessage(errorMsg);
    onLookup?.(null, errorMsg);
    }
    }

    useEffect(() => {
    return () => {
    if (controllerRef.current) controllerRef.current.abort();
    };
    }, []);

    const statusClass = styles[`status_${status}`] || '';
    const sizeClass = styles[`size_${size}`] || '';

    return (
    <div className={`${styles.container} ${className}`}>
    <label htmlFor={id} className={styles.label}>
    {label}
    {required && <span className={styles.required}> *</span>}
    </label>
    <div className={styles.inputWrapper}>
    <input
    ref={ref}
    id={id}
    type="text"
    inputMode="numeric"
    placeholder="000.000.000-00"
    value={displayValue}
    onChange={handleChange}
    maxLength={14}
    disabled={disabled}
    required={required}
    autoComplete="off"
    aria-invalid={status === 'invalid' || status === 'error'}
    aria-describedby={`${id}-feedback`}
    className={`${styles.input} ${statusClass} ${sizeClass}`}
    />
    {status === 'loading' && <span className={styles.spinner} />}
    {status === 'valid' && <span className={styles.checkmark}>{'\u2713'}</span>}
    {(status === 'invalid' || status === 'error') && (
    <span className={styles.errorIcon}>{'\u2717'}</span>
    )}
    </div>
    {message && (
    <p
    id={`${id}-feedback`}
    role="alert"
    className={`${styles.feedback} ${
    status === 'valid' ? styles.feedbackSuccess : styles.feedbackError
    }`}
    >
    {message}
    </p>
    )}
    {helpText && !message && (
    <p id={`${id}-feedback`} className={styles.helpText}>
    {helpText}
    </p>
    )}
    </div>
    );
});

CPFInput.displayName = 'CPFInput';
export default CPFInput;

Estilos com CSS variables

Para que o componente seja estilizável por diferentes temas, utilizamos CSS custom properties (variables).

/* CPFInput.module.css */
.container {
    --cpf-font-family: var(--ds-font-family, 'Inter', sans-serif);
    --cpf-border-color: var(--ds-border-color, #d1d5db);
    --cpf-border-radius: var(--ds-border-radius, 8px);
    --cpf-focus-color: var(--ds-focus-color, #3b82f6);
    --cpf-success-color: var(--ds-success-color, #10b981);
    --cpf-error-color: var(--ds-error-color, #ef4444);
    --cpf-text-color: var(--ds-text-color, #1f2937);
    --cpf-label-color: var(--ds-label-color, #374151);
    --cpf-help-color: var(--ds-help-color, #6b7280);
    font-family: var(--cpf-font-family);
}

.label {
    display: block;
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--cpf-label-color);
    margin-bottom: 4px;
}
.required { color: var(--cpf-error-color); }

.inputWrapper { position: relative; }

.input {
    width: 100%;
    border: 2px solid var(--cpf-border-color);
    border-radius: var(--cpf-border-radius);
    color: var(--cpf-text-color);
    outline: none;
    transition: border-color 0.2s;
    padding-right: 40px;
}
.input:focus { border-color: var(--cpf-focus-color); }
.input:disabled { opacity: 0.5; cursor: not-allowed; }

.size_sm { padding: 8px 12px; font-size: 0.875rem; }
.size_md { padding: 10px 14px; font-size: 1rem; }
.size_lg { padding: 14px 18px; font-size: 1.125rem; }

.status_valid { border-color: var(--cpf-success-color); }
.status_invalid, .status_error { border-color: var(--cpf-error-color); }

.spinner {
    position: absolute;
    right: 12px;
    top: 50%;
    transform: translateY(-50%);
    width: 18px;
    height: 18px;
    border: 2px solid var(--cpf-border-color);
    border-top-color: var(--cpf-focus-color);
    border-radius: 50%;
    animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: translateY(-50%) rotate(360deg); } }

.checkmark, .errorIcon {
    position: absolute;
    right: 12px;
    top: 50%;
    transform: translateY(-50%);
    font-size: 1.1rem;
}
.checkmark { color: var(--cpf-success-color); }
.errorIcon { color: var(--cpf-error-color); }

.feedback {
    font-size: 0.8rem;
    margin-top: 4px;
    min-height: 20px;
}
.feedbackSuccess { color: var(--cpf-success-color); }
.feedbackError { color: var(--cpf-error-color); }
.helpText { font-size: 0.8rem; color: var(--cpf-help-color); margin-top: 4px; }

Exemplos de uso

Uso básico (apenas validação local)

<CPFInput
    label="CPF do titular"
    required
    onValidate={(isValid, digits) => {
    console.log('Valido:', isValid, 'Digitos:', digits);
    }}
/>

Com consulta automática via API

<CPFInput
    label="CPF"
    apiKey={process.env.REACT_APP_CPFHUB_API_KEY}
    autoLookup
    timeout={10000}
    onLookup={(result, error) => {
    if (result) {
    setNome(result.name);
    setNascimento(result.birthDate);
    }
    }}
/>

Integração com React Hook Form

import { useForm, Controller } from 'react-hook-form';

function MeuFormulario() {
    const { control, handleSubmit } = useForm();

    return (
    <form onSubmit={handleSubmit(onSubmit)}>
    <Controller
    name="cpf"
    control={control}
    rules={{ required: true }}
    render={({ field }) => (
    <CPFInput
    value={field.value}
    onChange={(masked) => field.onChange(masked)}
    ref={field.ref}
    label="CPF"
    required
    />
    )}
    />
    </form>
    );
}

Tamanhos diferentes

<CPFInput size="sm" label="CPF (pequeno)" />
<CPFInput size="md" label="CPF (medio)" />
<CPFInput size="lg" label="CPF (grande)" />

Testes do componente

Um componente de design system precisa de cobertura de testes robusta.

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import CPFInput from './CPFInput';

describe('CPFInput', () => {
    it('renderiza com label', () => {
    render(<CPFInput label="CPF do cliente" />);
    expect(screen.getByLabelText('CPF do cliente')).toBeInTheDocument();
    });

    it('aplica mascara automaticamente', () => {
    render(<CPFInput />);
    const input = screen.getByPlaceholderText('000.000.000-00');
    fireEvent.change(input, { target: { value: '12345678901' } });
    expect(input).toHaveValue('123.456.789-01');
    });

    it('chama onValidate com true para CPF valido', () => {
    const onValidate = jest.fn();
    render(<CPFInput onValidate={onValidate} />);
    const input = screen.getByPlaceholderText('000.000.000-00');
    fireEvent.change(input, { target: { value: '52998224725' } });
    expect(onValidate).toHaveBeenCalledWith(true, '52998224725');
    });

    it('marca aria-invalid para CPF invalido', () => {
    render(<CPFInput />);
    const input = screen.getByPlaceholderText('000.000.000-00');
    fireEvent.change(input, { target: { value: '11111111111' } });
    expect(input).toHaveAttribute('aria-invalid', 'true');
    });

    it('respeita prop disabled', () => {
    render(<CPFInput disabled />);
    expect(screen.getByPlaceholderText('000.000.000-00')).toBeDisabled();
    });
});

Documentação para o design system

Todo componente de design system precisa de documentação clara. Inclua no Storybook ou na documentação interna:

  • Quando usar -- formulários que exigem CPF como input.
  • Quando não usar -- quando o CPF é exibido como texto estático (use um componente de display).
  • Props -- tabela completa com tipo, padrão e descrição.
  • Exemplos visuais -- cada estado (idle, typing, valid, invalid, loading, error).
  • Acessibilidade -- como o componente se comporta com leitores de tela e navegação por teclado.

A ANPD recomenda documentar a finalidade do tratamento de dados pessoais como o CPF, o que torna essa seção de documentação também relevante para conformidade com a LGPD.


Perguntas frequentes

O que é necessário para implementar validação de CPF neste contexto?

A validação de CPF exige uma chamada à API com o número do documento e a chave de autenticação. A CPFHub.io retorna o status do CPF, nome do titular e data de nascimento em cerca de 900ms, permitindo a verificação em tempo real durante o cadastro ou transação.

A API CPFHub.io funciona para todos os volumes de consulta?

Sim. O plano gratuito oferece 50 consultas por mês sem cartão de crédito — ideal para testes e projetos pequenos. Para volumes maiores, o plano Pro inclui 1.000 consultas mensais por R$149. Se o limite for ultrapassado, a API não bloqueia: cobra R$0,15 por consulta adicional.

Como garantir conformidade com a LGPD ao usar uma API de CPF?

Use o CPF apenas para a finalidade declarada ao titular, armazene apenas o necessário (não guarde o CPF cru se um token bastar), implemente controle de acesso aos logs de consulta e documente a base legal para o tratamento. A ANPD orienta que dados de identificação devem ser tratados com o princípio da necessidade.

Quanto tempo leva para integrar a API CPFHub.io?

A integração básica leva menos de 30 minutos: crie uma conta em cpfhub.io, gere a API key no painel e faça uma chamada GET para https://api.cpfhub.io/cpf/{CPF} com o header x-api-key. A documentação inclui exemplos em Python, Node.js, PHP, Java e outras linguagens.


Conclusão

Um componente reutilizável de input de CPF é um investimento que paga dividendos em todos os produtos da empresa. Ao centralizar máscara, validação e integração com API em um único componente, você garante consistência, reduz bugs e acelera o desenvolvimento de novos fluxos.

A integração com a API da CPFHub.io adiciona a camada de consulta em tempo real, preenchendo automaticamente nome e data de nascimento ao completar o CPF e eliminando erros de digitação nos cadastros.

Cadastre-se em cpfhub.io — 50 consultas mensais gratuitas, sem cartão de crédito — e comece hoje mesmo.

CPFHub.io

Pronto para integrar a API?

50 consultas gratuitas para testar agora. Sem cartão de crédito. Acesso imediato à documentação.

Redação CPFHub.io

Sobre a redação

Redação CPFHub.io

Time editorial especializado em APIs de CPF, identidade digital e compliance no mercado brasileiro. Produzimos guias técnicos, análises regulatórias e tutoriais sobre LGPD e KYC para desenvolvedores e líderes de produto.

WhatsAppFale conosco via WhatsApp