{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "countdown",
  "title": "Countdown",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Contagem regressiva em que cada dígito gira pelo caminho curto e vira o aviso de ao vivo quando chega a zero.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/countdown.tsx",
      "content": "\"use client\";\n\n/* Adaptado de Countdown — https://uiarc.dev/components/countdown — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui). */\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { DUR, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Countdown — a contagem pra um momento marcado: lançamento, live, fim de lote.\n *\n * Cada dígito é uma roda que gira pelo caminho curto, então a contagem anda pra\n * baixo e a virada de 00 pra 59 é UM passo, não cinquenta e nove. Quando chega\n * a zero, os dígitos se desfazem no rótulo de pronto (\"Ao vivo\") com um ponto\n * pulsando na cor da marca.\n *\n * ── O que não é óbvio ──────────────────────────────────────────────────────\n *\n * 1. **O alvo nunca depende do fuso de quem visita.** Número é epoch, `Date` é\n *    usada como veio, ISO com `Z` ou offset é exato. ISO SEM offset é hora de\n *    parede no `timeZone` (ou UTC): \"2026-11-20T19:00\" com\n *    `timeZone=\"America/Sao_Paulo\"` é o mesmo instante pra quem abre de Lisboa.\n * 2. **A dezena de minuto e segundo gira numa roda de seis.** É isso que deixa\n *    00 → 59 ser um passo só. A dezena de hora (com dias na tela) roda em três.\n * 3. **O relógio só bate com a peça na tela e a aba visível.** Voltar lê o\n *    relógio de novo, então nada acumula atraso. O tique acorda logo depois do\n *    segundo cheio CONTADO DO ALVO, não de quando a página carregou.\n * 4. **O fim é armado à parte.** `onComplete` dispara na hora mesmo com a aba\n *    escondida, e uma vez só, e só se a contagem estava correndo aqui.\n * 5. **A maior unidade absorve o resto.** Sem `days` em `units`, as horas\n *    passam de 24. Isso é decisão de copy (\"faltam 30 horas\"), não bug.\n * 6. **Antes de hidratar não há hora.** O servidor desenha zeros esmaecidos,\n *    nunca um número que o cliente vai desmentir no primeiro quadro.\n *\n * O leitor de tela ouve a frase inteira (\"2 dias, 4 horas\") num `role=\"timer\"`;\n * as rodas são `aria-hidden`. Com `prefers-reduced-motion` o dígito troca seco.\n */\n\nexport type CountdownUnit = \"days\" | \"hours\" | \"minutes\" | \"seconds\";\n\nexport interface CountdownProps {\n  /** O instante alvo. Ver a nota 1 sobre string sem offset. */\n  target: Date | number | string;\n  /** Fuso IANA pra string sem offset, como \"America/Sao_Paulo\". */\n  timeZone?: string;\n  /** Unidades na tela, da maior pra menor. A maior absorve o resto. */\n  units?: CountdownUnit[];\n  /** Dígitos grandes com rótulo, ou uma linha compacta pra faixa e tabela. */\n  variant?: \"large\" | \"compact\";\n  /** O que aparece quando o alvo passa. Os dígitos viram ele. */\n  completeLabel?: React.ReactNode;\n  /** Dispara uma vez, no instante em que chega a zero com a peça montada. */\n  onComplete?: () => void;\n  /** Nome falado, como \"A live começa em\". */\n  label?: string;\n  /** Relógio em epoch ms. Passe um sincronizado com o servidor quando o do aparelho não for confiável. */\n  clock?: () => number;\n  className?: string;\n}\n\nconst PADRAO: CountdownUnit[] = [\"days\", \"hours\", \"minutes\", \"seconds\"];\nconst SEGUNDOS: Record<CountdownUnit, number> = { days: 86_400, hours: 3_600, minutes: 60, seconds: 1 };\nconst LIMITE: Record<CountdownUnit, number> = { days: Infinity, hours: 24, minutes: 60, seconds: 60 };\n// [rótulo grande, letra compacta, singular, plural]\nconst NOMES: Record<CountdownUnit, [string, string, string, string]> = {\n  days: [\"Dias\", \"d\", \"dia\", \"dias\"],\n  hours: [\"Horas\", \"h\", \"hora\", \"horas\"],\n  minutes: [\"Minutos\", \"min\", \"minuto\", \"minutos\"],\n  seconds: [\"Segundos\", \"s\", \"segundo\", \"segundos\"],\n};\nconst MAX_TIMEOUT = 2_147_483_647;\nconst relogioDoSistema = () => Date.now();\n\nconst MOLA_TIQUE = { type: \"spring\", visualDuration: 0.26, bounce: 0.12 } as const;\nconst MOLA_LISA = { type: \"spring\", visualDuration: 0.4, bounce: 0 } as const;\nconst MOLA_FORMA = { type: \"spring\", visualDuration: 0.42, bounce: 0.16 } as const;\n\n/** Distância do fuso pro UTC num instante, em ms. */\nfunction offsetDoFuso(epoch: number, timeZone: string) {\n  const partes = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone,\n    hourCycle: \"h23\",\n    year: \"numeric\",\n    month: \"2-digit\",\n    day: \"2-digit\",\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n    second: \"2-digit\",\n  }).formatToParts(new Date(epoch));\n  const pega = (t: string) => Number(partes.find((p) => p.type === t)?.value ?? 0);\n  return (\n    Date.UTC(pega(\"year\"), pega(\"month\") - 1, pega(\"day\"), pega(\"hour\"), pega(\"minute\"), pega(\"second\")) -\n    Math.floor(epoch / 1000) * 1000\n  );\n}\n\n/** Resolve o alvo em epoch ms sem nunca ler o fuso do aparelho. */\nexport function toEpoch(target: Date | number | string, timeZone?: string) {\n  if (typeof target === \"number\") return target;\n  if (target instanceof Date) return target.getTime();\n  const parede = /^(\\d{4})-(\\d{2})-(\\d{2})(?:[T ](\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.\\d+)?)?)?$/.exec(target.trim());\n  if (!parede) return Date.parse(target);\n  const utc = Date.UTC(\n    +parede[1]!,\n    +parede[2]! - 1,\n    +parede[3]!,\n    +(parede[4] ?? 0),\n    +(parede[5] ?? 0),\n    +(parede[6] ?? 0),\n  );\n  if (!timeZone) return utc;\n  // Duas passadas assentam o offset em cima de uma troca de horário de verão.\n  const chute = utc - offsetDoFuso(utc, timeZone);\n  return utc - offsetDoFuso(chute, timeZone);\n}\n\nfunction partesDe(segundos: number, units: CountdownUnit[]) {\n  return units.map((unit, i) => ({\n    unit,\n    value: i === 0 ? Math.floor(segundos / SEGUNDOS[unit]) : Math.floor(segundos / SEGUNDOS[unit]) % LIMITE[unit],\n  }));\n}\n\n/** Tamanho de cada roda. Ver a nota 2. */\nfunction basesDe(unit: CountdownUnit, maior: boolean, n: number) {\n  return Array.from({ length: n }, (_, i) => {\n    const dezena = i === n - 2 && n === 2 && !maior;\n    return dezena ? (unit === \"hours\" ? 3 : 6) : 10;\n  });\n}\n\nconst nada = () => () => {};\nconst assinarVisibilidade = (avisar: () => void) => {\n  document.addEventListener(\"visibilitychange\", avisar);\n  return () => document.removeEventListener(\"visibilitychange\", avisar);\n};\n\n/** Um dígito na roda. A distância dele até a posição da roda decide onde fica e quão nítido aparece. */\nfunction Glifo({ posicao, digito, base }: { posicao: MotionValue<number>; digito: number; base: number }) {\n  const desvio = useTransform(posicao, (p) => ((((digito - p) % base) + base * 1.5) % base) - base / 2);\n  const y = useTransform(desvio, (d) => `${d}em`);\n  const opacity = useTransform(desvio, (d) => Math.max(0, 1 - Math.abs(d)));\n  const visibility = useTransform(desvio, (d) => (Math.abs(d) >= 1 ? \"hidden\" : \"visible\"));\n  const filter = useTransform(desvio, (d) =>\n    Math.abs(d) < 0.02 || Math.abs(d) >= 1 ? \"none\" : `blur(${(Math.abs(d) * 2).toFixed(2)}px)`,\n  );\n  return (\n    <motion.span className=\"absolute inset-x-0 top-[var(--pena)] text-center\" style={{ y, opacity, filter, visibility }}>\n      {digito}\n    </motion.span>\n  );\n}\n\n// A janela da roda esfuma nas bordas: o dígito girando some na pena, não é cortado.\nconst MASCARA =\n  \"linear-gradient(to bottom, transparent, currentColor calc(var(--pena) * 1.5), currentColor calc(100% - var(--pena) * 1.5), transparent)\";\n\nfunction Roda({ digito, base, atraso, reduzir }: { digito: number; base: number; atraso: number; reduzir: boolean }) {\n  const posicao = useMotionValue(0);\n  const roda = React.useRef({ digito: 0, alvo: 0, revelada: false });\n\n  React.useEffect(() => {\n    const r = roda.current;\n    if (r.digito === digito) {\n      r.revelada = true;\n      return;\n    }\n    const sobe = (digito - r.digito + base) % base;\n    const desce = base - sobe;\n    r.alvo += desce <= sobe ? -desce : sobe;\n    r.digito = digito;\n    const primeira = !r.revelada;\n    r.revelada = true;\n    // Animação nova no mesmo valor assume a que está em voo, com a velocidade\n    // dela. Sem cleanup de propósito: um re-run nunca deixa a roda no meio.\n    if (reduzir) posicao.jump(r.alvo);\n    else animate(posicao, r.alvo, primeira ? { ...MOLA_LISA, delay: atraso } : MOLA_TIQUE);\n  }, [base, atraso, digito, posicao, reduzir]);\n\n  return (\n    <motion.span\n      className=\"relative inline-block overflow-hidden [--pena:0.16em] -my-[var(--pena)] py-[var(--pena)]\"\n      style={{ maskImage: MASCARA, WebkitMaskImage: MASCARA }}\n      initial={{ width: 0, opacity: 0 }}\n      // motion-lint-disable-next-line layout-property-animation  dígito entra e os vizinhos precisam abrir espaço\n      animate={{ width: \"auto\", opacity: 1 }}\n      exit={{ width: 0, opacity: 0 }}\n      transition={reduzir ? INSTANT : MOLA_FORMA}\n    >\n      <span className=\"invisible\">0</span>\n      {Array.from({ length: base }, (_, v) => (\n        <Glifo key={v} posicao={posicao} digito={v} base={base} />\n      ))}\n    </motion.span>\n  );\n}\n\nexport function Countdown({\n  target,\n  timeZone,\n  units = PADRAO,\n  variant = \"large\",\n  completeLabel = \"Ao vivo\",\n  onComplete,\n  label = \"Tempo restante\",\n  clock = relogioDoSistema,\n  className,\n}: CountdownProps) {\n  const raiz = React.useRef<HTMLDivElement>(null);\n  const hidratado = React.useSyncExternalStore(nada, () => true, () => false);\n  const reduzir = !!useReducedMotion() && hidratado;\n  const fim = toEpoch(target, timeZone);\n  const valido = Number.isFinite(fim);\n  const unidades = units.length ? [...units].sort((a, b) => SEGUNDOS[b] - SEGUNDOS[a]) : PADRAO;\n\n  const visivel = React.useSyncExternalStore(\n    assinarVisibilidade,\n    () => document.visibilityState === \"visible\",\n    () => true,\n  );\n  const naTela = useInView(raiz, { amount: 0 });\n  const correndo = visivel && naTela && valido;\n  const [agora, setAgora] = React.useState<number | null>(null);\n  const relogio = React.useRef(clock);\n  React.useLayoutEffect(() => {\n    relogio.current = clock;\n  });\n\n  React.useEffect(() => {\n    if (!correndo) return;\n    let t: ReturnType<typeof setTimeout>;\n    const tique = () => {\n      const hora = relogio.current();\n      setAgora(hora);\n      const falta = fim - hora;\n      if (falta > 0) t = setTimeout(tique, (falta % 1000 || 1000) + 8);\n    };\n    t = setTimeout(tique, 0);\n    return () => clearTimeout(t);\n  }, [fim, correndo]);\n\n  React.useEffect(() => {\n    if (!valido) return;\n    const falta = fim - relogio.current();\n    if (falta <= 0) return;\n    const t = setTimeout(() => setAgora(relogio.current()), Math.min(MAX_TIMEOUT, falta + 8));\n    return () => clearTimeout(t);\n  }, [fim, valido]);\n\n  const falta = agora === null || !valido ? null : Math.max(0, fim - agora);\n  const pronto = falta === 0;\n  const aoCompletar = React.useRef(onComplete);\n  React.useLayoutEffect(() => {\n    aoCompletar.current = onComplete;\n  });\n  const estavaCorrendo = React.useRef<number | null>(null);\n  React.useEffect(() => {\n    if (falta === null) return;\n    if (falta > 0) {\n      estavaCorrendo.current = fim;\n      return;\n    }\n    if (estavaCorrendo.current === fim) {\n      estavaCorrendo.current = null;\n      aoCompletar.current?.();\n    }\n  }, [fim, falta]);\n\n  const segundos = falta === null ? 0 : Math.ceil(falta / 1000);\n  const partes = partesDe(segundos, unidades);\n  const pendente = falta === null;\n  const compacto = variant === \"compact\";\n\n  // A casca vai numa mola até o tamanho do que segura: os dígitos viram o\n  // rótulo de pronto sem pulo. offsetWidth é medida local, imune ao palco escalado.\n  const conteudo = React.useRef<HTMLDivElement>(null);\n  const largura = useMotionValue<number | \"auto\">(\"auto\");\n  const altura = useMotionValue<number | \"auto\">(\"auto\");\n  React.useLayoutEffect(() => {\n    const no = conteudo.current;\n    if (!no || typeof ResizeObserver === \"undefined\") return;\n    let primeira = true;\n    const obs = new ResizeObserver(() => {\n      const w = no.offsetWidth;\n      const h = no.offsetHeight;\n      if (primeira || reduzir) {\n        largura.jump(w);\n        altura.jump(h);\n        primeira = false;\n        return;\n      }\n      animate(largura, w, MOLA_FORMA);\n      animate(altura, h, MOLA_LISA);\n    });\n    obs.observe(no);\n    return () => obs.disconnect();\n  }, [altura, reduzir, largura]);\n\n  const falado = pronto\n    ? typeof completeLabel === \"string\"\n      ? completeLabel\n      : \"Terminou\"\n    : pendente\n      ? \"\"\n      : partes\n          .filter((p, i) => p.value > 0 || i === partes.length - 1)\n          // Segundos só entram na fala no último minuto: anunciar cada um é ruído.\n          .filter((p) => segundos < 60 || p.unit !== \"seconds\")\n          .map((p) => `${p.value} ${p.value === 1 ? NOMES[p.unit][2] : NOMES[p.unit][3]}`)\n          .join(\", \");\n\n  const troca = reduzir\n    ? {\n        initial: { opacity: 0 },\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        transition: { duration: DUR.fast },\n      }\n    : {\n        initial: { opacity: 0, scale: 0.92, filter: \"blur(4px)\" },\n        animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n        exit: { opacity: 0, scale: 0.92, filter: \"blur(4px)\", transition: { duration: DUR.fast } },\n        transition: MOLA_FORMA,\n      };\n\n  return (\n    <div\n      ref={raiz}\n      role=\"timer\"\n      aria-label={label}\n      data-variant={variant}\n      data-complete={pronto || undefined}\n      className={cn(\n        \"relative min-w-0 text-foreground\",\n        compacto ? \"inline-flex align-middle\" : \"@container flex justify-center\",\n        className,\n      )}\n    >\n      <motion.div\n        className={cn(\n          \"relative flex justify-center\",\n          compacto &&\n            \"overflow-hidden rounded-[var(--r-pill)] border [transition:border-color_var(--dur-slow)_var(--ease-out),background-color_var(--dur-slow)_var(--ease-out)]\",\n          compacto &&\n            (pronto\n              ? \"border-[var(--brand)] bg-[color-mix(in_oklab,var(--brand)_12%,var(--card))]\"\n              : \"border-border bg-card\"),\n        )}\n        style={{ width: largura, height: altura }}\n      >\n        <div\n          ref={conteudo}\n          aria-hidden=\"true\"\n          className={cn(\"relative inline-flex flex-none items-center justify-center\", compacto && \"min-h-8 px-3\")}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {pronto ? (\n              <motion.span\n                key=\"pronto\"\n                className={cn(\n                  \"inline-flex items-center gap-[0.4em] whitespace-nowrap font-medium\",\n                  compacto\n                    ? \"text-corpo leading-snug\"\n                    : \"text-[clamp(1.75rem,11cqi,2.5rem)] leading-tight tracking-tight\",\n                )}\n                {...troca}\n              >\n                <span className=\"relative size-[max(0.3em,8px)] flex-none rounded-full bg-[var(--brand)]\">\n                  <span className=\"absolute inset-0 rounded-full bg-[var(--brand)] opacity-60 motion-safe:animate-ping\" />\n                </span>\n                {completeLabel}\n              </motion.span>\n            ) : (\n              <motion.span\n                key=\"correndo\"\n                className={cn(\n                  \"inline-flex items-start whitespace-nowrap\",\n                  compacto\n                    ? \"gap-1.5 text-corpo font-medium leading-snug\"\n                    : \"text-[clamp(1.75rem,12.5cqi,3rem)] font-medium leading-none tracking-tight\",\n                )}\n                {...troca}\n              >\n                {partes.map((p, g) => {\n                  const texto = String(p.value).padStart(2, \"0\");\n                  const bases = basesDe(p.unit, g === 0, texto.length);\n                  return (\n                    <span key={p.unit} className=\"inline-flex items-start\">\n                      {!compacto && g > 0 && (\n                        <span className=\"-translate-y-[0.06em] px-[0.12em] leading-none text-muted-foreground\">\n                          :\n                        </span>\n                      )}\n                      <span\n                        className={cn(compacto ? \"inline-flex items-baseline gap-px\" : \"inline-grid justify-items-center gap-1.5\")}\n                        // Coluna com piso igual: num card estreito os rótulos passam\n                        // dos dígitos, e os dois-pontos ficariam desiguais.\n                        style={compacto ? undefined : { minWidth: \"min(calc((100cqi - 1.6em) / 4), 3.15rem)\" }}\n                      >\n                        <span\n                          className={cn(\n                            \"inline-flex tabular-nums [transition:opacity_var(--dur-slow)_var(--ease-out)]\",\n                            pendente && \"opacity-30\",\n                          )}\n                        >\n                          <AnimatePresence initial={false}>\n                            {[...texto].map((c, i) => (\n                              <Roda\n                                key={`${p.unit}${texto.length - i}`}\n                                digito={Number(c)}\n                                base={bases[i]!}\n                                atraso={Math.min((g * 2 + i) * 0.035, 0.3)}\n                                reduzir={reduzir}\n                              />\n                            ))}\n                          </AnimatePresence>\n                        </span>\n                        <span\n                          className={cn(\n                            \"text-muted-foreground\",\n                            compacto ? \"text-corpo font-normal leading-snug\" : \"text-apoio leading-none tracking-normal\",\n                          )}\n                        >\n                          {NOMES[p.unit][compacto ? 1 : 0]}\n                        </span>\n                      </span>\n                    </span>\n                  );\n                })}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n      <span className=\"sr-only\">{falado}</span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Contagem até lançamento, evento ou prazo que vira aviso de ao vivo quando chega a zero",
      "evite": "Número que só apura: count-up; horário de loja aberta, store-status"
    }
  },
  "categories": [
    "conteudo",
    "contagem",
    "lancamento",
    "prazo",
    "timer",
    "evento"
  ],
  "type": "registry:ui"
}