{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fullscreen-button",
  "title": "Fullscreen Button",
  "description": "A button that shows one element — a chart, a data table, a map, a video player, a preview pane — full-screen on its own, and keeps telling the truth about it afterwards. Reach for it wherever a panel is too small for the thing inside it: a dashboard chart somebody wants to read properly, a wide data table or log viewer, an embedded map, a code or markdown preview, an image or PDF viewer, a diagram or canvas, a kiosk or presentation view, a video or camera feed. Common asks it answers: \"fullscreen button react\", \"react fullscreen component\", \"shadcn fullscreen\", \"requestFullscreen react\", \"expand chart to fullscreen\", \"fullscreen a div react\", \"maximize panel react\", \"react-full-screen alternative\", \"screenfull.js alternative\", \"use-fullscreen hook\", \"exit fullscreen button stuck\", \"fullscreen state wrong after escape\", \"escape key breaks my fullscreen toggle\", \"document.fullscreenElement react\", \"fullscreenchange listener react\", \"requestFullscreen not working\", \"requestFullscreen NotAllowedError\", \"fullscreen permissions policy iframe\", \"requestFullscreen ios safari not working\", \"element fullscreen iphone\", \"fullscreen api safari prefix\", \"webkitRequestFullscreen\", \"全画面 ボタン react\", \"フルスクリーン 切り替え react\", \"Escape で全画面が解除されると表示がずれる\". Official shadcn/ui has nothing of the kind, and the measurement is not close. Fetching all sixty-three registry entries today (sixty-two are fetchable; questionnaire is listed and 404s on both style tracks) and grepping 255,796 bytes of source: requestFullscreen, exitFullscreen, fullscreenElement, fullscreenchange, fullscreenEnabled, webkitRequestFullscreen, allowFullScreen and the bare word fullscreen are every one of them zero hits. The four matches for \"expand\" are sidebar's expanded/collapsed state and a has-aria-expanded rule on a table row. So an agent asked for this writes it from scratch, and the version it writes has a specific bug in it. The bug is a `const [isFullscreen, setIsFullscreen] = useState(false)` flipped inside the click handler. It demos perfectly. Then the user presses Escape — which is how most people leave full screen, along with F11 and the browser's own chrome — and none of those go through the button. The boolean stays true, the label reads \"Exit fullscreen\" over a windowed page, and clicking it now asks to *leave* a full screen nobody is in, so the button is stuck in the wrong state for good and no amount of pressing fixes it. The only real state is `document.fullscreenElement`, and the only way to stay level with it is to subscribe to `fullscreenchange` and re-read. That is what this does: entering never writes the state, it only asks — the event is the sole writer — so the label cannot drift from the browser, whatever route the user took out. The comparison is the half that is usually missed even by implementations that do subscribe. `!!document.fullscreenElement` is a property of the page, not of your element: put a fullscreen button on both a chart and a table, expand the chart, and the table's button lights up as well and offers to exit something it does not own. The state here is `document.fullscreenElement === target`, and `exit()` refuses to act unless that holds, because `exitFullscreen` is document-wide and would otherwise cancel a full screen that belongs to another component. Two things about the request itself. It is called with nothing awaited ahead of it, because the browser only grants it while the click is still the active user gesture — measure the element or fetch something first and it rejects with NotAllowedError, in production, on a button that worked in development where the await was fast. And the returned promise really does reject: an iframe embedded without `allow=\"fullscreen\"` has the method and is refused by permissions policy, which arrives asynchronously, after the gesture is spent. That leaves the environment the API is simply not in. On an iPhone, Safari has element full-screen for `<video>` and nothing else — `requestFullscreen` is absent, not refused — so a button that assumes the method exists is dead on the most common phone on the web, silently, with nothing in the console. Rather than detect and disappear, this keeps working by other means: where the API is missing, or refused, it covers the viewport with CSS instead and reports `mode: \"css\"` so you can tell the difference. That fallback is written as inline styles rather than class names on purpose — a panel wearing `h-64 max-w-md rounded-lg` ignores `position: fixed; inset: 0` and stays a small rounded card in the corner, because an explicit height wins over an over-constrained box — and every property's previous inline value is saved and put back on exit. It also paints a backdrop when the target's own background is transparent, taken from the nearest ancestor that paints one so it stays right in both themes, and it wires Escape by hand, since the real API's Escape is not there to inherit. Unmounting restores the target: the alternative is a panel pinned over the page with no way back. Feature detection never reaches the render, so the server and the first client render agree and nothing hydrates twice; `isFullscreenSupported` and the hook's `isSupported` are there for when you want to hide your own control, and both read the permissions policy rather than just the method. The state is carried by `aria-pressed` — an icon swap is not something a screen reader reports — the icon is `aria-hidden`, `iconOnly` keeps the label as the accessible name instead of dropping it, focus-visible rings are the shadcn ones, and it is a real `<button type=\"button\">` so it will not submit the form it sits in. Older Safari's `webkit` spellings are handled for the request, the exit and the event. The API: `targetRef` is the element to expand, plus `enterLabel`, `exitLabel`, `iconOnly`, `cssFallback` and `onFullscreenChange` — which fires on every flip including the ones the user made with Escape, and is not called `onChange` because that is a native attribute of `<button>`. `useFullscreen(targetRef)` returns `{ isFullscreen, mode, isSupported, enter, exit, toggle }` for a control you lay out yourself. One thing worth knowing before you place it: put the button **inside** the element it expands. Everything outside the full-screen element is not rendered, so a button sitting beside the chart vanishes the moment it is pressed, taking the keyboard focus with it and leaving Escape as the only way back. Within pulld it sits next to the components that make a panel readable rather than bigger: image-comparison and image-crop work on one image, scroll-shadow and virtual-list handle a pane that is too small to show everything, and print-button is the other way of getting content out of a cramped box. Distinct from a dialog: this is the browser's own full screen over the whole display, with no overlay, no focus trap and no scrim. One file, one dependency (lucide-react for the two icons), and every colour is a shadcn token, so light and dark follow on their own.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/fullscreen-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Maximize, Minimize } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * Which mechanism is showing the target full-screen right now.\n *\n * `\"native\"` is never written by a click. It is only ever written by the `fullscreenchange`\n * handler after re-reading `document.fullscreenElement`, which is the one thing that makes this\n * component correct: the user can leave full-screen at any moment with Escape, F11 or the\n * browser's own chrome, and none of those go through this button. A component that flips its own\n * boolean when pressed shows \"Exit full screen\" forever after the first Escape, and no amount of\n * clicking fixes it because the button and the browser now disagree about the world.\n *\n * `\"css\"` is the opposite: there is no event behind it, so that half is owned state and Escape has\n * to be wired by hand.\n */\ntype FullscreenMode = \"off\" | \"native\" | \"css\"\n\n/** The prefixed spellings older Safari ships instead of the standard ones. */\ninterface PrefixedDocument extends Document {\n  webkitFullscreenElement?: Element | null\n  webkitExitFullscreen?: () => void\n}\n\ninterface PrefixedElement extends HTMLElement {\n  webkitRequestFullscreen?: () => Promise<void> | void\n}\n\n/**\n * The inline styles the CSS fallback writes onto the target, and the reason each one is there.\n *\n * Inline rather than a class list because these have to beat whatever the target already wears:\n * a panel with `h-64 max-w-md rounded-lg` keeps its 256px height under `position: fixed; inset: 0`\n * — an explicit height wins over an over-constrained box — so a fallback built from classes\n * produces a small rounded card pinned to the top-left corner instead of a full screen. Every\n * property's previous inline value is saved and restored on exit, so nothing is guessed on the way\n * back out.\n */\nconst CSS_FULLSCREEN_STYLE: Readonly<Record<string, string>> = {\n  position: \"fixed\",\n  inset: \"0\",\n  margin: \"0\",\n  // `auto` so the inset drives the size instead of a declared width/height.\n  width: \"auto\",\n  height: \"auto\",\n  \"max-width\": \"none\",\n  \"max-height\": \"none\",\n  // A min-width wider than the viewport would scroll the page sideways.\n  \"min-width\": \"0\",\n  \"min-height\": \"0\",\n  // Rounded corners would let the page show through at the four corners.\n  \"border-radius\": \"0\",\n  \"z-index\": \"50\",\n  overflow: \"auto\",\n}\n\n/** The document that owns `target` — not the global one, which is a different document in an iframe. */\nfunction documentOf(target: HTMLElement | null): Document | null {\n  if (target) return target.ownerDocument ?? null\n  return typeof document === \"undefined\" ? null : document\n}\n\n/** The element the browser considers full-screen, or null. */\nfunction fullscreenElementOf(doc: Document): Element | null {\n  const prefixed = doc as PrefixedDocument\n  return doc.fullscreenElement ?? prefixed.webkitFullscreenElement ?? null\n}\n\n/**\n * Whether this element can be shown full-screen by the browser at all.\n *\n * False on an iPhone, where Safari has element full-screen for `<video>` and nothing else — the\n * method is simply absent — and false inside an iframe that was embedded without\n * `allow=\"fullscreen\"`, where the method exists and the request is refused.\n */\nexport function isFullscreenSupported(target: HTMLElement | null): boolean {\n  const doc = documentOf(target)\n  if (!doc) return false\n  const el = (target ?? doc.documentElement) as PrefixedElement | null\n  if (!el) return false\n  const hasMethod =\n    typeof el.requestFullscreen === \"function\" || typeof el.webkitRequestFullscreen === \"function\"\n  if (!hasMethod) return false\n  // `fullscreenEnabled` is how a document reports the permissions policy. Only trust it when the\n  // browser actually implements it; an older engine that leaves it undefined is not saying \"no\".\n  const enabled = doc.fullscreenEnabled\n  return enabled === undefined ? true : enabled\n}\n\n/** `rgba(0, 0, 0, 0)` and `transparent` are what a computed style says when nothing is painted. */\nfunction isTransparent(color: string): boolean {\n  return !color || color === \"transparent\" || /,\\s*0\\s*\\)$/.test(color)\n}\n\n/**\n * A colour to paint behind the target while the CSS fallback is up, or null if it paints itself.\n *\n * The real API puts an opaque backdrop behind the full-screen element; `position: fixed` does not,\n * so a chart with a transparent background would be laid over the page it came from and the old\n * layout would read straight through it. The colour is taken from the nearest ancestor that paints\n * one — a resolved `rgb()` from the live page, which stays right in both themes without this\n * component having to know which token the design uses.\n */\nfunction backdropFor(target: HTMLElement): string | null {\n  const view = target.ownerDocument?.defaultView\n  if (!view || typeof view.getComputedStyle !== \"function\") return null\n  if (!isTransparent(view.getComputedStyle(target).backgroundColor)) return null\n  let node = target.parentElement\n  while (node) {\n    const color = view.getComputedStyle(node).backgroundColor\n    if (!isTransparent(color)) return color\n    node = node.parentElement\n  }\n  return null\n}\n\ninterface UseFullscreenOptions {\n  /**\n   * Cover the viewport with CSS where the browser has no element full-screen, or refuses it.\n   * Defaults to true: an iPhone is not an edge case, and a button that does nothing at all there\n   * is worse than one that expands the panel by other means.\n   */\n  cssFallback?: boolean\n  /**\n   * Called when the target enters or leaves full screen, however it happened — Escape included.\n   *\n   * Not `onChange`: that is a native attribute of `<button>`, and a prop by that name would be\n   * spread onto the element as React's change handler as well as read here.\n   */\n  onFullscreenChange?: (isFullscreen: boolean) => void\n}\n\ninterface UseFullscreenResult {\n  /** True while the target is full-screen by either mechanism. */\n  isFullscreen: boolean\n  /** Which mechanism is doing it — `\"css\"` means the fallback is up rather than the real thing. */\n  mode: FullscreenMode\n  /**\n   * Whether the browser can do this natively. Starts as `true` so the server and the first client\n   * render agree, then settles on mount; read it to hide your own control, not to pick a label.\n   */\n  isSupported: boolean\n  /** Ask for full screen. Call it inside the click, with nothing awaited first. */\n  enter: () => void\n  /** Leave full screen, whichever mechanism is up. */\n  exit: () => void\n  /** Enter or leave, depending on where the browser says we are right now. */\n  toggle: () => void\n}\n\n/**\n * The whole behaviour, for a control you lay out yourself.\n *\n * `targetRef` points at the element to expand — a chart, a table, a map, a preview pane. Put your\n * button *inside* that element: everything outside it is not rendered while it is full-screen, so\n * a button that lives beside the chart vanishes the moment it is pressed, taking the keyboard\n * focus with it and leaving Escape as the only way back.\n */\nexport function useFullscreen(\n  targetRef: React.RefObject<HTMLElement | null>,\n  { cssFallback = true, onFullscreenChange }: UseFullscreenOptions = {}\n): UseFullscreenResult {\n  const [mode, setMode] = React.useState<FullscreenMode>(\"off\")\n  const [isSupported, setIsSupported] = React.useState(true)\n\n  // The inline values the fallback overwrote, so exiting restores the target exactly.\n  const savedStyle = React.useRef<Record<string, string> | null>(null)\n\n  const onChangeRef = React.useRef(onFullscreenChange)\n  React.useEffect(() => {\n    onChangeRef.current = onFullscreenChange\n  }, [onFullscreenChange])\n\n  const clearCss = React.useCallback(() => {\n    const target = targetRef.current\n    const saved = savedStyle.current\n    savedStyle.current = null\n    if (!target || !saved) return\n    for (const [prop, value] of Object.entries(saved)) {\n      if (value) target.style.setProperty(prop, value)\n      else target.style.removeProperty(prop)\n    }\n    target.removeAttribute(\"data-fullscreen\")\n  }, [targetRef])\n\n  const applyCss = React.useCallback(() => {\n    const target = targetRef.current\n    if (!target || savedStyle.current) return\n    const saved: Record<string, string> = {}\n    const wanted = { ...CSS_FULLSCREEN_STYLE } as Record<string, string>\n    const backdrop = backdropFor(target)\n    if (backdrop) wanted[\"background-color\"] = backdrop\n    for (const [prop, value] of Object.entries(wanted)) {\n      saved[prop] = target.style.getPropertyValue(prop)\n      target.style.setProperty(prop, value)\n    }\n    savedStyle.current = saved\n    // A hook for the caller's own styling, and the same name the real thing answers to in CSS.\n    target.setAttribute(\"data-fullscreen\", \"css\")\n    setMode(\"css\")\n  }, [targetRef])\n\n  const exit = React.useCallback(() => {\n    if (savedStyle.current) {\n      clearCss()\n      setMode(\"off\")\n      return\n    }\n    const target = targetRef.current\n    const doc = documentOf(target)\n    if (!doc) return\n    // Only ours. Another component on the page may own the current full-screen element, and\n    // calling exit on the document would yank it out from under them.\n    if (!target || fullscreenElementOf(doc) !== target) return\n    const prefixed = doc as PrefixedDocument\n    if (typeof doc.exitFullscreen === \"function\") void doc.exitFullscreen()\n    else prefixed.webkitExitFullscreen?.()\n  }, [clearCss, targetRef])\n\n  const enter = React.useCallback(() => {\n    const target = targetRef.current as PrefixedElement | null\n    if (!target) return\n    const request =\n      typeof target.requestFullscreen === \"function\"\n        ? target.requestFullscreen\n        : target.webkitRequestFullscreen\n\n    if (typeof request !== \"function\") {\n      if (cssFallback) applyCss()\n      return\n    }\n\n    // Called with nothing awaited ahead of it. `requestFullscreen` is only granted while the click\n    // is still the active user gesture, so a version that measures the element or fetches\n    // something first rejects with NotAllowedError — on a button that worked in development,\n    // because the await was fast there.\n    let result: Promise<void> | void\n    try {\n      result = request.call(target)\n    } catch {\n      if (cssFallback) applyCss()\n      return\n    }\n    if (result && typeof result.then === \"function\") {\n      result.then(undefined, () => {\n        // The refusal arrives after the gesture is spent — an iframe without `allow=\"fullscreen\"`\n        // is the common one — so the fallback is the only thing left that can still work.\n        if (cssFallback) applyCss()\n      })\n    }\n    // Nothing is set here on success: `fullscreenchange` is what says we got it.\n  }, [applyCss, cssFallback, targetRef])\n\n  const toggle = React.useCallback(() => {\n    const target = targetRef.current\n    const doc = documentOf(target)\n    const active = !!savedStyle.current || (!!doc && !!target && fullscreenElementOf(doc) === target)\n    if (active) exit()\n    else enter()\n  }, [enter, exit, targetRef])\n\n  // The subscription. This is the only writer of `\"native\"`.\n  React.useEffect(() => {\n    const target = targetRef.current\n    const doc = documentOf(target)\n    if (!doc) return\n\n    setIsSupported(isFullscreenSupported(target))\n\n    const sync = () => {\n      const current = targetRef.current\n      const isNative = !!current && fullscreenElementOf(doc) === current\n      setMode((prev) => {\n        if (isNative) return \"native\"\n        // Leaving native full-screen must not clear a fallback that is legitimately up.\n        return prev === \"css\" ? \"css\" : \"off\"\n      })\n    }\n\n    // Read once on mount too: this component can be mounted into an element that is already\n    // full-screen — a remount behind a changed `key`, or a panel that renders late.\n    sync()\n    doc.addEventListener(\"fullscreenchange\", sync)\n    doc.addEventListener(\"webkitfullscreenchange\", sync)\n    return () => {\n      doc.removeEventListener(\"fullscreenchange\", sync)\n      doc.removeEventListener(\"webkitfullscreenchange\", sync)\n    }\n  }, [targetRef])\n\n  // Escape, for the fallback only. The real API has its own, and doubling it up would exit twice.\n  React.useEffect(() => {\n    if (mode !== \"css\") return\n    const doc = documentOf(targetRef.current)\n    if (!doc) return\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return\n      event.preventDefault()\n      clearCss()\n      setMode(\"off\")\n    }\n    doc.addEventListener(\"keydown\", onKeyDown)\n    return () => doc.removeEventListener(\"keydown\", onKeyDown)\n  }, [clearCss, mode, targetRef])\n\n  // Unmounting with the fallback up would leave the target pinned over the page for good. Native\n  // full-screen is deliberately left alone: the browser drops it when the element goes away, and\n  // forcing an exit because a button unmounted would be the button overruling the user.\n  React.useEffect(() => clearCss, [clearCss])\n\n  const isFullscreen = mode !== \"off\"\n  const reported = React.useRef(isFullscreen)\n  React.useEffect(() => {\n    if (reported.current === isFullscreen) return\n    reported.current = isFullscreen\n    onChangeRef.current?.(isFullscreen)\n  }, [isFullscreen])\n\n  return { isFullscreen, mode, isSupported, enter, exit, toggle }\n}\n\ninterface FullscreenButtonProps\n  extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"children\">,\n    UseFullscreenOptions {\n  /** The element to expand. Put this button inside it — see `useFullscreen`. */\n  targetRef: React.RefObject<HTMLElement | null>\n  /** Label while the target is windowed. */\n  enterLabel?: string\n  /** Label while the target is full-screen. */\n  exitLabel?: string\n  /** Drop the visible text and keep it as the accessible name. */\n  iconOnly?: boolean\n}\n\n/**\n * A button that shows one element full-screen, and keeps telling the truth about it afterwards.\n */\nexport function FullscreenButton({\n  targetRef,\n  enterLabel = \"Fullscreen\",\n  exitLabel = \"Exit fullscreen\",\n  iconOnly = false,\n  cssFallback,\n  onFullscreenChange,\n  onClick,\n  className,\n  ...props\n}: FullscreenButtonProps) {\n  const { isFullscreen, mode, toggle } = useFullscreen(targetRef, { cssFallback, onFullscreenChange })\n\n  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {\n    onClick?.(event)\n    if (event.defaultPrevented) return\n    // Straight into the gesture — see the note in `enter`.\n    toggle()\n  }\n\n  const label = isFullscreen ? exitLabel : enterLabel\n  const Icon = isFullscreen ? Minimize : Maximize\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleClick}\n      // The state is carried by `aria-pressed` rather than by the icon, which a screen reader does\n      // not report, and it is re-derived from the document on every change — so it is still right\n      // after the user presses Escape.\n      aria-pressed={isFullscreen}\n      aria-label={iconOnly ? label : undefined}\n      data-state={isFullscreen ? \"fullscreen\" : \"windowed\"}\n      data-fullscreen-mode={mode}\n      className={cn(\n        \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n        iconOnly ? \"w-9\" : \"px-4\",\n        className\n      )}\n      {...props}\n    >\n      <Icon className=\"h-4 w-4\" aria-hidden=\"true\" />\n      {iconOnly ? null : label}\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
