Voltar ao blog
nuxtvue-jsjavascripttypescriptfrontend

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

VisitCardGenerator: como construí um gerador de cartões de visita em PDF do zero com Nuxt 3

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 no onMounted, 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.vue e BusinessBack.vue (cards): puramente apresentacionais. Recebem props, calculam estilos via computed e 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

text
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:

ts
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

ts
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

ts
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):

ts
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.vue escala o card com transform: 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-image captura 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:

ts
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):

ts
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:

ts
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:

ts
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:

ts
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:

ts
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!

Feito com e Vue.js
2026 © Larissa Santos