VisitCardGenerator: como construí um gerador de cartões de visita em PDF do zero com Nuxt 3
15 de março de 2026 às 08:15 - Por Larissa Santos

Criar um cartão de visita profissional deveria ser simples. Mas a realidade é que a maioria das ferramentas disponíveis exige conta, cobra pelo download, ou te coloca numa interface de drag-and-drop onde você passa mais tempo ajustando posição de elementos do que produzindo algo de valor.
A ideia do VisitCardGenerator surgiu exatamente daí: um gerador onde qualquer pessoa preenche campos, visualiza o resultado em tempo real e baixa o PDF pronto para gráfica, sem cadastro, sem custo, sem complicação.
Neste artigo vou detalhar as decisões técnicas, os desafios que enfrentei e o que aprendi no processo.
Decisões técnicas
Nuxt 3
O Nuxt foi escolhido por entregar SSR, SEO nativo e performance de carregamento, essenciais para um produto que precisa de visibilidade orgânica. O file-based routing elimina configuração manual de rotas, o sistema de módulos acelera integrações, e o ecossistema do NuxtUI com Tailwind v4 entregou velocidade de desenvolvimento sem abrir mão de qualidade visual. A escolha veio da experiência prévia com a tecnologia e da confiança no que ela entrega.
Pinia
O estado do editor tem uma característica importante: precisa ser acessado por pelo menos três camadas diferentes, a página que orquestra, o formulário que edita, e os cards que renderizam. Passar tudo via props e emits geraria um prop drilling desnecessário e acoplamento entre componentes que não precisam se conhecer. O Pinia resolve isso com uma store central que qualquer componente acessa diretamente, sem intermediários.
html-to-image no lugar de html2canvas
O html2canvas é a biblioteca mais popular para captura de DOM, mas não suporta o espaço de cor oklch(), que é exatamente o que o Tailwind v4 usa para gerar suas cores. O resultado seria um card completamente branco no PDF. O html-to-image não tem essa limitação e entrega o mesmo resultado visual com uma API equivalente.
jsPDF
Biblioteca consolidada para geração de PDF no browser, sem dependência de servidor. Permite definir o formato exato em milímetros, essencial para gerar o PDF no tamanho padrão de gráficas (88.9x50.8mm) sem distorção.
TypeScript
Usado em todo o projeto. Com um formulário que tem ~15 campos tipados e componentes que recebem subconjuntos diferentes desses campos como props, o TypeScript eliminou uma classe inteira de bugs e tornou o autocomplete confiável durante o desenvolvimento.
Arquitetura do editor
O editor é a peça central do projeto. Visualmente é uma tela dividida em duas colunas: à esquerda o formulário de edição, à direita o preview do cartão em tempo real. No mobile, o preview ocupa a tela inteira e o formulário abre como um painel deslizante a partir de baixo.
A arquitetura foi pensada com separação clara de responsabilidades, seguindo o princípio da responsabilidade única: cada camada faz exatamente uma coisa e não sabe mais do que precisa.
useEditorStore(Pinia): fonte da verdade do conteúdo do cartão. Gerencia o estado do formulário, as imagens, os getters de props dos cards, a persistência no localStorage e o reset.useCardExport(composable): toda a lógica de captura dos elementos do DOM e geração do PDF. Não conhece a store nem o formulário, recebe os elementos e o nome do arquivo.editor.vue(página): orquestra. Carrega o estado salvo noonMounted, conecta o preview com os getters da store e delega a exportação ao composable.Editor/Form/index.vue(formulário): acessa a store diretamente e tem autonomia sobre sua própria apresentação: qual tab está ativa, validação de e-mail, upload de imagem, reset com modal de confirmação.BusinessFront.vueeBusinessBack.vue(cards): puramente apresentacionais. Recebem props, calculam estilos viacomputede renderizam. Não têm estado próprio e podem ser usados em qualquer contexto sem adaptação.
Esse isolamento tem consequências práticas: mudar a lógica de exportação não afeta os cards, adicionar um campo no formulário não exige tocar no preview, e os cards são reutilizados sem modificação no editor, na landing page e na exportação do PDF.
Estrutura de componentes
components/
Animations/ ← animações reutilizáveis (ex: reveal on scroll)
Editor/
Card/
BusinessFront.vue ← frente do cartão (apresentação pura)
BusinessBack.vue ← verso do cartão (apresentação pura)
Preview.vue ← preview escalado para a tela
Form/
index.vue ← formulário principal com tabs
ColorPicker.vue ← seletor de cor customizado
PatternPicker.vue ← seletor de padrão geométrico
Field.vue ← wrapper de campo com label
Label.vue ← label reutilizável
Header.vue ← cabeçalho do editor
Footer.vue ← rodapé do editor
LandingPage/
Card.vue ← exemplo interativo com flip 3D
// demais seções da landing page...
OgImage/ ← componente de OG Image customizado
O formulário foi quebrado em componentes menores dentro de Form/ seguindo o SRP: ColorPicker só sabe selecionar cores, PatternPicker só sabe exibir e selecionar padrões, Field só sabe envolver um input com label e espaçamento consistente. O index.vue orquestra esses blocos nas tabs, mas não conhece os detalhes internos de nenhum deles.
Os cards são abertos para extensão via props, novos campos, alinhamentos e tamanhos de logo foram adicionados sem modificar a estrutura base, mas fechados para modificação de comportamento externo, já que não expõem estado interno nem emitem eventos. É o princípio aberto/fechado aplicado a componentes Vue.
A store useEditorStore
A store centraliza tudo que pertence ao domínio do cartão:
export const useEditorStore = defineStore('editor', () => {
const form = reactive<EditorForm>({ ...DEFAULT_FORM })
const logoPreview = ref<string | null>(null)
const bgImages = ref<Record<string, string | null>>({ front: null, back: null })
const cardFrontProps = computed(() => ({ ... }))
const cardBackProps = computed(() => ({ ... }))
const isFormValid = computed(() => form.companyName.trim().length > 0)
function save() { /* persiste no localStorage */ }
function load() { /* recupera do localStorage */ }
function reset() { /* limpa estado e storage */ }
return { form, logoPreview, bgImages, cardFrontProps, cardBackProps, isFormValid, save, load, reset }
})
Os cardFrontProps e cardBackProps são getters computados que encapsulam toda a lógica de composição, incluindo a regra de patternFront vs pattern para frente e verso, e a intensidade do padrão compartilhada entre os dois lados. A página e os componentes consomem esses getters diretamente.
O estado do formulário e as imagens ficam separados intencionalmente: logoPreview e bgImages armazenam strings base64 que podem ter centenas de KB. Misturá-los com os campos primitivos do formulário criaria um objeto pesado sendo monitorado desnecessariamente.
A persistência é explícita: save() é chamado pelo botão no formulário, e load() no onMounted da página. O usuário decide quando salvar, o que é mais previsível e evita gravar centenas de KB a cada keystroke.
O composable useCardExport
export function useCardExport() {
const isGenerating = ref(false)
async function exportPDF(
frontEl: HTMLElement,
backEl: HTMLElement,
fileName: string
) {
isGenerating.value = true
try {
// captura com html-to-image, monta o PDF com jsPDF, faz o download
} finally {
isGenerating.value = false
}
}
return { isGenerating, exportPDF }
}
Não conhece a store, não conhece o formulário. Recebe os elementos do DOM e o nome do arquivo, só isso. O isGenerating é exposto para a UI reagir durante a geração.
Como a página ficou
const store = useEditorStore()
const { isGenerating, exportPDF } = useCardExport()
onMounted(() => store.load())
async function handleGenerate() {
const frontEl = previewRef.value?.cardFrenteRef?.$el as HTMLElement
const backEl = previewRef.value?.cardVersoRef?.$el as HTMLElement
const fileName = (store.form.companyName || 'cartao')
.toLowerCase()
.replace(/\s+/g, '-')
await exportPDF(frontEl, backEl, fileName)
}
A página tem ~40 linhas de lógica. Sem estado de formulário, sem composição de props, sem persistência. Só orquestração.
Como o card é construído
BusinessFront.vue recebe props e converte tudo em estilos inline via computed. O card tem dimensões fixas de 520x296px, que correspondem ao tamanho real de um cartão de visita padrão para gráficas (88.9x50.8mm em 150dpi):
const cardStyle = computed(() => ({
width: '520px',
height: '296px',
padding: '28px 34px 26px',
background: backgroundColor.value,
fontFamily: "'DM Sans', sans-serif",
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxSizing: 'border-box'
}))
Todos os outros elementos, cores, fontes, espaçamentos, posicionamento, seguem o mesmo padrão: computeds derivados das props.
Por que 100% inline e não Tailwind? Porque o card serve três contextos diferentes ao mesmo tempo:
- Preview: o
Preview.vueescala o card comtransform: scale()para caber na tela sem alterar seus 520x296px reais. - Landing page: o mesmo componente aparece no exemplo interativo com efeito 3D de perspectiva no mouse, sem adaptação.
- Exportação: o
html-to-imagecaptura o DOM diretamente. Se qualquer estilo viesse de classe Tailwind ou variável CSS externa, a captura poderia falhar silenciosamente ou produzir resultado diferente do preview.
O inline style garante que o que está no DOM é exatamente o que vai para o PDF.
Alinhamento e padrões geométricos
O card suporta 4 opções de alinhamento: custom, left, center e right. Em vez de condicionais espalhadas pelo template, toda a configuração visual de cada opção vive num objeto estático chamado ALIGN_MAP. Cada entrada define o comportamento do cabeçalho, alinhamento do texto, posição dos contatos e se o logo aparece antes ou depois do nome. O template usa apenas o resultado do computed que consulta esse objeto, sem nenhum v-if de layout. Adicionar um novo alinhamento é só adicionar uma entrada no objeto.
Os padrões geométricos são SVGs gerados por funções que recebem a cor de destaque como parâmetro, garantindo que o padrão sempre combine com as cores escolhidas. A intensidade é controlada por um slider que ajusta a opacidade do SVG no DOM.
O problema com html2canvas e oklch()
O desafio técnico mais relevante do projeto: o Tailwind v4 gera cores em oklch(), e o html2canvas simplesmente não suporta esse espaço de cor. O resultado era um card completamente branco no PDF.
A solução foi trocar para html-to-image com skipFonts: true:
const [pngFront, pngBack] = await Promise.all([
toPng(frontEl, { pixelRatio: 3, skipFonts: true }),
toPng(backEl, { pixelRatio: 3, skipFonts: true })
])
O skipFonts: true é necessário porque o html-to-image tenta serializar as fontes externas (Google Fonts) e falha silenciosamente em alguns navegadores. Com a flag, ele ignora a tentativa e usa o que já está renderizado no browser, o resultado visual é idêntico.
Geração do PDF
O PDF final tem duas páginas em formato 88.9x50.8mm (tamanho padrão para cartões de visita em gráficas brasileiras):
const pdf = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: [88.9, 50.8]
})
pdf.addImage(pngFront, 'PNG', 0, 0, 88.9, 50.8)
pdf.addPage()
pdf.addImage(pngBack, 'PNG', 0, 0, 88.9, 50.8)
Um detalhe importante: antes da captura, os dois cards precisam estar visíveis no DOM mesmo que apenas um esteja sendo exibido ao usuário. A solução foi forçar display: flex/block temporariamente, capturar, e restaurar o estado original:
const prevFront = frontEl.style.display
frontEl.style.display = 'flex'
// captura...
frontEl.style.display = prevFront
Ícones de contato como SVG inline
Os ícones de telefone, e-mail, site e endereço no card são SVGs inline gerados por computed:
const icon = (path: string) =>
computed(
() =>
`<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="${accentColor.value}" stroke-width="2">${path}</svg>`
)
const phoneIcon = icon(`<path d="M22 16.92v3..."/>`)
A cor do ícone (stroke) acompanha automaticamente a accentColor. Por ser SVG inline via v-html, é capturado perfeitamente pelo html-to-image, diferente de fontes de ícones externas como MDI, que seriam ignoradas com skipFonts: true.
SEO e OG Image
Para o OG Image customizado usei o sistema de componentes do @nuxtjs/seo:
defineOgImageComponent('OgImageVisitCardOg', {
title: 'Editor de Cartão',
description: 'Personalize cores, logo e padrões...'
})
Um aprendizado: o LinkedIn cacheia OG Images de forma agressiva. Para forçar a atualização após deploy, é preciso usar o LinkedIn Post Inspector para invalidar o cache manualmente.
Layouts no Nuxt
O projeto tem duas páginas com layouts completamente diferentes: landing page e editor. No Nuxt, isso é resolvido com definePageMeta:
definePageMeta({ layout: 'editor-layout' })
definePageMeta({ layout: 'land-page-layout' })
É o equivalente ao meta: {} do Vue Router convencional, sem necessidade de middleware ou lógica adicional.
Conclusão
O VisitCardGenerator cresceu além do escopo original à medida que eu resolvia problemas reais: captura de DOM com cores oklch, geração de PDF em tamanho exato para gráfica, alinhamento flexível do layout, responsividade do editor, gerenciamento de estado com Pinia.
O resultado é uma ferramenta que eu mesma usaria, e que qualquer pessoa pode usar, sem cadastro e sem pagar nada.
O projeto está no ar em visitcard-larisantos.vercel.app.
Se você tiver dúvidas sobre alguma das decisões técnicas ou quiser trocar ideia sobre o projeto, me chama!