{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "site-footer",
  "type": "registry:block",
  "title": "Site footer",
  "description": "A website footer with link columns and newsletter, a minimal layout, and a large fading brand wordmark.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/segmented-control.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/site-footer/site-footer.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { FormEvent, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUpRight, Check } from \"lucide-react\";\nimport { Button } from \"../../button/button\";\nimport SegmentedControl from \"../../segmented-control/segmented-control\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\n\nexport type SiteFooterVariant = \"columns\" | \"minimal\" | \"logo\";\n\nexport interface SiteFooterLink {\n  label: string;\n  href?: string;\n  /** Opens in a new tab and shows an outward arrow. */\n  external?: boolean;\n}\n\nexport interface SiteFooterColumn {\n  title: string;\n  links: SiteFooterLink[];\n}\n\nexport interface SiteFooterNewsletter {\n  title?: string;\n  description?: string;\n  placeholder?: string;\n  /** Called with a valid address. Throw or reject to show an error; resolve to show the subscribed state. */\n  onSubscribe?: (email: string) => void | Promise<void>;\n}\n\nexport interface SiteFooterProps {\n  /** `columns` pairs a newsletter with link columns, `minimal` is one quiet row, `logo` ends in a large fading brand wordmark. */\n  variant?: SiteFooterVariant;\n  brand?: { name: string; href?: string; mark?: ReactNode };\n  /** One short line under the brand. */\n  tagline?: string;\n  columns?: SiteFooterColumn[];\n  /** Links for the minimal variant. Defaults to the first link of each column. */\n  links?: SiteFooterLink[];\n  /** Small links beside the copyright, such as Privacy and Terms. */\n  legal?: SiteFooterLink[];\n  socials?: SiteFooterLink[];\n  /** Newsletter signup. Pass null to hide it. */\n  newsletter?: SiteFooterNewsletter | null;\n  /** A system status link. Pass null to hide it. */\n  status?: { label: string; tone?: \"success\" | \"warning\" | \"danger\"; href?: string } | null;\n  /** Year in the copyright line. */\n  year?: number;\n  /** Called for every link that is pressed. Links without an href render as buttons and only call this. */\n  onNavigate?: (link: SiteFooterLink) => void;\n  className?: string;\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\n\nexport const siteFooterExampleColumns: SiteFooterColumn[] = [\n  {\n    title: \"Library\",\n    links: [{ label: \"Components\" }, { label: \"Blocks\" }, { label: \"AI interface\" }, { label: \"Charts\" }],\n  },\n  {\n    title: \"Guides\",\n    links: [{ label: \"Install\" }, { label: \"Brand book\" }, { label: \"Writing rules\" }, { label: \"Motion\" }],\n  },\n  {\n    title: \"Project\",\n    links: [{ label: \"Changelog\" }, { label: \"Registry\" }, { label: \"Card guidelines\" }, { label: \"Tokens\" }],\n  },\n];\nconst exampleLegal: SiteFooterLink[] = [{ label: \"License\" }, { label: \"Credits\" }];\nconst exampleSocials: SiteFooterLink[] = [\n  { label: \"Repository\", external: true },\n  { label: \"Releases\", external: true },\n];\nconst linkClass =\n  \"group/link inline-flex cursor-pointer items-center gap-[3px] bg-transparent py-0.5 text-left text-sm/[inherit] text-ink-2 no-underline transition-[color] duration-fast ease-standard hover:text-ink focus-visible:text-ink active:text-ink motion-reduce:transition-none\";\nconst inlineList = \"flex flex-wrap items-center gap-x-4 gap-y-1\";\nconst copyright = \"text-sm/[inherit] text-ink-3\";\nconst ends = \"flex flex-wrap items-center gap-x-5 gap-y-2\";\nconst statusTone = {\n  success: \"[--tone:var(--green)]\",\n  warning: \"[--tone:var(--orange)]\",\n  danger: \"[--tone:var(--red)]\",\n} as const;\n/** Read once when the module loads, so rendering stays pure. */\nconst CURRENT_YEAR = new Date().getFullYear();\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\n\nfunction FooterLink({\n  link,\n  onNavigate,\n  className,\n}: {\n  link: SiteFooterLink;\n  onNavigate?: (link: SiteFooterLink) => void;\n  className?: string;\n}) {\n  const content = (\n    <>\n      {link.label}\n      {link.external && (\n        <ArrowUpRight\n          className=\"text-ink-3 transition-transform duration-fast ease-standard group-hover/link:translate-x-px group-hover/link:-translate-y-px motion-reduce:transition-none\"\n          size={13}\n          strokeWidth={2}\n          aria-hidden=\"true\"\n        />\n      )}\n    </>\n  );\n  const onClick = () => onNavigate?.(link);\n  if (link.href)\n    return (\n      <a\n        className={className ?? linkClass}\n        href={link.href}\n        onClick={onClick}\n        {...(link.external ? { target: \"_blank\", rel: \"noopener noreferrer\" } : {})}\n      >\n        {content}\n      </a>\n    );\n  return (\n    <button type=\"button\" className={className ?? linkClass} onClick={onClick}>\n      {content}\n    </button>\n  );\n}\n\n/** Email signup that validates in place, keeps its width while the button morphs, and confirms without a toast. */\nfunction Newsletter({ title, description, placeholder = \"you@example.com\", onSubscribe }: SiteFooterNewsletter) {\n  const id = useId();\n  const reduced = !!useReducedMotion();\n  const [email, setEmail] = useState(\"\");\n  const [state, setState] = useState<\"idle\" | \"loading\" | \"done\">(\"idle\");\n  const [error, setError] = useState<string | null>(null);\n  const [touched, setTouched] = useState(false);\n\n  const validate = (value: string) =>\n    !value.trim() ? \"Enter your email address\" : EMAIL.test(value.trim()) ? null : \"That email doesn't look right\";\n\n  async function submit(event: FormEvent) {\n    event.preventDefault();\n    if (state !== \"idle\") return;\n    setTouched(true);\n    const problem = validate(email);\n    setError(problem);\n    if (problem) return;\n    setState(\"loading\");\n    try {\n      await (onSubscribe ? onSubscribe(email.trim()) : new Promise((resolve) => setTimeout(resolve, 900)));\n      setState(\"done\");\n    } catch {\n      setState(\"idle\");\n      setError(\"We couldn't subscribe you. Try again in a moment\");\n    }\n  }\n\n  const message = state === \"done\" ? `Check ${email.trim()} to confirm` : (error ?? description);\n  const tone = state === \"done\" ? \"success\" : error ? \"error\" : \"hint\";\n\n  return (\n    <form\n      className=\"mt-5 grid max-w-[400px] gap-2 @max-[520px]/site-footer:max-w-none\"\n      onSubmit={submit}\n      noValidate\n      aria-labelledby={title ? `${id}-title` : undefined}\n    >\n      {title && (\n        <h2 id={`${id}-title`} className=\"text-sm/[inherit] font-medium\">\n          {title}\n        </h2>\n      )}\n      <div\n        className={cn(\n          \"flex h-control-md items-center gap-2 rounded-control border border-line bg-surface py-[3px] pr-[3px] pl-4 transition-[border-color] duration-fast ease-standard focus-within:border-line-strong motion-reduce:transition-none\",\n          error && \"border-red focus-within:border-red\",\n        )}\n        data-invalid={error ? \"\" : undefined}\n        data-done={state === \"done\" ? \"\" : undefined}\n      >\n        <label className=\"sr-only\" htmlFor={`${id}-email`}>\n          Email address\n        </label>\n        <input\n          id={`${id}-email`}\n          className={cn(\n            \"w-full min-w-0 flex-1 bg-transparent text-sm/[inherit] text-ink placeholder:text-ink-3\",\n            state === \"done\" && \"text-ink-2\",\n          )}\n          type=\"email\"\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          placeholder={placeholder}\n          value={email}\n          readOnly={state !== \"idle\"}\n          aria-invalid={error ? true : undefined}\n          aria-describedby={`${id}-message`}\n          onChange={(event) => {\n            setEmail(event.target.value);\n            if (touched) setError(validate(event.target.value));\n          }}\n          onBlur={() => {\n            if (email) {\n              setTouched(true);\n              setError(validate(email));\n            }\n          }}\n        />\n        <Button\n          type=\"submit\"\n          size=\"sm\"\n          variant=\"primary\"\n          loading={state === \"loading\"}\n          className=\"flex-none\"\n          aria-disabled={state === \"done\" || undefined}\n        >\n          {state === \"done\" ? (\n            <>\n              <Check size={15} strokeWidth={2.25} aria-hidden=\"true\" />\n              Subscribed\n            </>\n          ) : (\n            \"Subscribe\"\n          )}\n        </Button>\n      </div>\n      <div className=\"relative min-h-5\" id={`${id}-message`} aria-live=\"polite\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {message && (\n            <motion.p\n              key={message}\n              className={cn(\"text-xs/5 text-ink-3\", tone === \"error\" && \"text-red\", tone === \"success\" && \"text-green\")}\n              data-tone={tone}\n              role={tone === \"error\" ? \"alert\" : undefined}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      y: -4,\n                      filter: `blur(${motionTokens.blur.subtle}px)`,\n                      transition: { duration: motionTokens.duration.instant },\n                    }\n              }\n              transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: enter }}\n            >\n              {message}\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n    </form>\n  );\n}\n\nconst footerClass =\n  \"relative @container/site-footer overflow-hidden border-t border-line bg-page px-(--footer-pad) pt-12 pb-6 font-body tracking-body text-ink [--footer-pad:--spacing(6)]\";\nconst minimalRow = \"flex flex-wrap items-center justify-between gap-x-6 gap-y-3\";\n\n/**\n * A website footer in three layouts: link columns with a newsletter signup, a single quiet row, and a closing\n * variant where the brand name rises in as an oversized wordmark, cropped by the bottom edge and fading into the page.\n */\nexport const SiteFooter = forwardRef<HTMLElement, SiteFooterProps>(function SiteFooter(\n  {\n    variant = \"columns\",\n    brand = { name: \"Iris UI\" },\n    tagline = \"Interface components that move with intent.\",\n    columns = siteFooterExampleColumns,\n    links,\n    legal = exampleLegal,\n    socials = exampleSocials,\n    newsletter = { title: \"Get the release notes\", description: \"One email per release. Unsubscribe anytime.\" },\n    status = { label: \"All systems normal\", tone: \"success\" },\n    year = CURRENT_YEAR,\n    onNavigate,\n    className,\n  },\n  ref,\n) {\n  const reduced = !!useReducedMotion();\n  const brandNode = (\n    <FooterLink\n      link={{ label: brand.name, href: brand.href }}\n      onNavigate={onNavigate}\n      className=\"cursor-pointer bg-transparent text-base/[inherit] font-medium text-ink no-underline\"\n    />\n  );\n  const brandWithMark = (\n    <span className=\"inline-flex items-center gap-2\">\n      {brand.mark}\n      {brandNode}\n    </span>\n  );\n  const statusClass = cn(\n    \"inline-flex cursor-pointer items-center gap-2 bg-transparent py-0.5 text-sm/[inherit] text-ink-2 no-underline hover:text-ink\",\n    statusTone[status?.tone ?? \"success\"],\n  );\n  const statusDot = (\n    <span\n      className=\"size-[7px] rounded-full bg-(--tone) shadow-[0_0_0_3px_color-mix(in_oklab,var(--tone)_18%,transparent)]\"\n      aria-hidden=\"true\"\n    />\n  );\n  const statusNode =\n    status &&\n    (status.href ? (\n      <a\n        className={statusClass}\n        href={status.href}\n        data-tone={status.tone ?? \"success\"}\n        onClick={() => onNavigate?.({ label: status.label, href: status.href })}\n      >\n        {statusDot}\n        {status.label}\n      </a>\n    ) : (\n      <button\n        type=\"button\"\n        className={statusClass}\n        data-tone={status.tone ?? \"success\"}\n        onClick={() => onNavigate?.({ label: status.label })}\n      >\n        {statusDot}\n        {status.label}\n      </button>\n    ));\n  const socialNode = socials.length > 0 && (\n    <ul className={inlineList} aria-label=\"Social\">\n      {socials.map((link) => (\n        <li key={link.label}>\n          <FooterLink link={link} onNavigate={onNavigate} />\n        </li>\n      ))}\n    </ul>\n  );\n  const legalRow = (\n    <div className=\"mt-12 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-line-soft pt-5 @max-[520px]/site-footer:flex-col @max-[520px]/site-footer:items-start\">\n      <div className={ends}>\n        <span className={copyright}>\n          © {year} {brand.name}\n        </span>\n        {legal.length > 0 && (\n          <ul className={inlineList} aria-label=\"Legal\">\n            {legal.map((link) => (\n              <li key={link.label}>\n                <FooterLink link={link} onNavigate={onNavigate} />\n              </li>\n            ))}\n          </ul>\n        )}\n      </div>\n      <div className={ends}>\n        {statusNode}\n        {socialNode}\n      </div>\n    </div>\n  );\n  const columnNav = (\n    <nav\n      className=\"grid grid-cols-3 gap-6 @max-[520px]/site-footer:grid-cols-2 @max-[520px]/site-footer:gap-x-4 @max-[520px]/site-footer:gap-y-8\"\n      aria-label=\"Footer\"\n    >\n      {columns.map((column) => (\n        <div key={column.title}>\n          <h2 className=\"mb-3 text-sm/[inherit] font-medium\">{column.title}</h2>\n          <ul className=\"grid gap-2\">\n            {column.links.map((link) => (\n              <li key={link.label}>\n                <FooterLink link={link} onNavigate={onNavigate} />\n              </li>\n            ))}\n          </ul>\n        </div>\n      ))}\n    </nav>\n  );\n\n  if (variant === \"minimal\") {\n    const rowLinks =\n      links ??\n      columns\n        .map((column) => column.links[0])\n        .filter((link): link is SiteFooterLink => Boolean(link))\n        .concat(legal.slice(0, 2));\n    return (\n      <footer ref={ref} className={cn(footerClass, \"pt-8 pb-6\", className)}>\n        <div className={minimalRow}>\n          {brandWithMark}\n          <nav aria-label=\"Footer\">\n            <ul className={inlineList}>\n              {rowLinks.map((link) => (\n                <li key={link.label}>\n                  <FooterLink link={link} onNavigate={onNavigate} />\n                </li>\n              ))}\n            </ul>\n          </nav>\n        </div>\n        <div className={cn(minimalRow, \"mt-6 border-t border-line-soft pt-5\")}>\n          <span className={copyright}>\n            © {year} {brand.name}. {tagline}\n          </span>\n          <div className={ends}>\n            {statusNode}\n            {socialNode}\n          </div>\n        </div>\n      </footer>\n    );\n  }\n\n  return (\n    <footer ref={ref} className={cn(footerClass, variant === \"logo\" && \"pb-0\", className)}>\n      <div className=\"grid grid-cols-[minmax(0,1fr)_minmax(0,1.25fr)] gap-12 @max-[760px]/site-footer:grid-cols-1 @max-[760px]/site-footer:gap-10\">\n        <div className=\"grid min-w-0 content-start gap-3\">\n          {brandWithMark}\n          {tagline && <p className=\"max-w-[320px] text-sm leading-body text-ink-2\">{tagline}</p>}\n          {newsletter && variant === \"columns\" && <Newsletter {...newsletter} />}\n        </div>\n        {columnNav}\n      </div>\n      {legalRow}\n      {variant === \"logo\" && (\n        <motion.div\n          className=\"pointer-events-none mx-[calc(var(--footer-pad)*-1)] mt-10 mask-[linear-gradient(to_bottom,#000_18%,transparent_96%)]\"\n          aria-hidden=\"true\"\n          initial={reduced ? false : { opacity: 0, y: 40 }}\n          whileInView={{ opacity: 1, y: 0 }}\n          viewport={{ once: true, amount: 0.3 }}\n          transition={{ duration: reduced ? 0 : 0.9, ease: enter }}\n        >\n          {/* The brand name, oversized and set low, fading toward the bottom edge. */}\n          <div className=\"block w-full overflow-hidden text-center font-display text-[clamp(96px,24vw,300px)]/[.78] font-semibold tracking-[-.055em] whitespace-nowrap text-[color-mix(in_oklab,var(--ink)_9%,transparent)]\">\n            {brand.name}\n          </div>\n        </motion.div>\n      )}\n    </footer>\n  );\n});\n\nSiteFooter.displayName = \"SiteFooter\";\n\nconst variantOptions = [\n  { value: \"columns\", label: \"Columns\" },\n  { value: \"minimal\", label: \"Minimal\" },\n  { value: \"logo\", label: \"Logo\" },\n];\n\n/** Preview: the footer with a switch between its three layouts and a note of the last link pressed. */\nexport function SiteFooterBlock({ variant: initial = \"columns\" }: { variant?: SiteFooterVariant }) {\n  const [variant, setVariant] = useState<SiteFooterVariant>(initial);\n  const [last, setLast] = useState<string | null>(null);\n  const reduced = !!useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | null>(null);\n  // The frame springs between layout heights instead of snapping; the new layout fades in where it sits.\n  useLayoutEffect(() => {\n    const node = contentRef.current;\n    if (!node) return;\n    setHeight(node.offsetHeight);\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setHeight(node.offsetHeight));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [variant]);\n  return (\n    <div className=\"grid w-full justify-items-center gap-4\">\n      <SegmentedControl\n        label=\"Footer layout\"\n        options={variantOptions}\n        value={variant}\n        onValueChange={(value) => setVariant(value as SiteFooterVariant)}\n      />\n      <div className=\"w-full overflow-hidden rounded-[20px] border border-line bg-page\">\n        <motion.div\n          className=\"overflow-hidden\"\n          initial={false}\n          animate={{ height: height ?? \"auto\" }}\n          transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}\n        >\n          <motion.div\n            ref={contentRef}\n            key={variant}\n            initial={reduced ? false : { opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={{ duration: motionTokens.duration.standard, ease: enter }}\n          >\n            <SiteFooter variant={variant} onNavigate={(link) => setLast(link.label)} className=\"border-t-0\" />\n          </motion.div>\n        </motion.div>\n      </div>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {last ? `Opened ${last}` : \"\"}\n      </p>\n    </div>\n  );\n}\n\nexport default SiteFooterBlock;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/site-footer/site-footer.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/site-footer",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "SiteFooter",
    "docs": "https://irisui.dev/components/site-footer"
  }
}
