{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dna-helix",
  "title": "DNA helix",
  "description": "A procedural B-DNA double helix built from a sequence, with selectable base pairs and both strands listed below.",
  "dependencies": [
    "three@^0.186.0",
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "devDependencies": [
    "@types/three@^0.186.0"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/base-nova/dna-helix/dna-helix.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\nimport {\n  AmbientLight,\n  CatmullRomCurve3,\n  Color,\n  CylinderGeometry,\n  DirectionalLight,\n  Group,\n  HemisphereLight,\n  InstancedMesh,\n  Matrix4,\n  Mesh,\n  MeshStandardMaterial,\n  PerspectiveCamera,\n  Quaternion,\n  Raycaster,\n  Scene,\n  SphereGeometry,\n  Spherical,\n  TubeGeometry,\n  Vector2,\n  Vector3,\n  WebGLRenderer,\n} from \"three\"\nimport { OrbitControls } from \"three/addons/controls/OrbitControls.js\"\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nexport type Nucleobase = \"A\" | \"C\" | \"G\" | \"T\"\n\nexport type BasePair = {\n  index: number\n  /** Base on the drawn 5′→3′ strand. */\n  base: Nucleobase\n  /** Its Watson–Crick partner on the complementary strand. */\n  partner: Nucleobase\n  hydrogenBonds: 2 | 3\n}\n\nexport type DnaHelixHandle = {\n  zoom: (factor: number) => void\n  rotate: (azimuth: number, polar?: number) => void\n  reset: () => void\n}\n\nexport type DnaHelixProps = {\n  /** One strand, read 5′ to 3′. Whitespace is ignored; only A, C, G and T are allowed. */\n  sequence?: string\n  /** Base pair index, from 0 at the 5′ end. */\n  selectedIndex?: number | null\n  onSelectionChange?: (index: number, pair: BasePair) => void\n  /** Spins the helix slowly about its axis until the user pauses it or moves the camera. Off when the user prefers reduced motion. Defaults to true. */\n  autoRotate?: boolean\n  /** Shows the selectable sequence of both strands below the stage. Defaults to true. */\n  strands?: boolean\n  /** Shows the rotation, zoom and reset buttons. Defaults to true. */\n  controls?: boolean\n  label?: string\n  className?: string\n  ref?: React.Ref<DnaHelixHandle>\n}\n\nconst COMPLEMENT: Record<Nucleobase, Nucleobase> = {\n  A: \"T\",\n  T: \"A\",\n  C: \"G\",\n  G: \"C\",\n}\n\n/** Colours shared with the molecule viewer's nucleotide colouring. */\nexport const nucleobaseColors: Record<Nucleobase, string> = {\n  A: \"#e15759\",\n  T: \"#4e79a7\",\n  G: \"#59a14f\",\n  C: \"#edc948\",\n}\n\nexport const nucleobaseNames: Record<Nucleobase, string> = {\n  A: \"Adenine\",\n  T: \"Thymine\",\n  G: \"Guanine\",\n  C: \"Cytosine\",\n}\n\n/** Pairs each base of a 5′→3′ strand with its complement. Throws on letters other than A, C, G and T. */\nexport function dnaBasePairs(sequence: string): BasePair[] {\n  const bases = sequence.replace(/\\s+/g, \"\").toUpperCase()\n  const invalid = bases.match(/[^ACGT]/)\n  if (invalid)\n    throw new Error(\n      `The sequence may contain only A, C, G and T; it has “${invalid[0]}”.`\n    )\n  return [...bases].map((letter, index) => {\n    const base = letter as Nucleobase\n    return {\n      index,\n      base,\n      partner: COMPLEMENT[base],\n      hydrogenBonds: base === \"G\" || base === \"C\" ? 3 : 2,\n    }\n  })\n}\n\n// B-DNA: 3.38 Å rise and 34.3° twist per base pair (10.5 pairs a turn), with\n// phosphates about 9 Å from the axis. The two backbones sit closer on one\n// side, which leaves the minor and major grooves.\nconst RISE = 3.38\nconst TWIST = (34.3 * Math.PI) / 180\nconst RADIUS = 9\nconst MINOR_GROOVE = (140 * Math.PI) / 180\nconst STAGE_TONE = new Color(\"#e4e3dc\")\nconst STRAND_COLORS = [\"#6b7a8c\", \"#9a8770\"] as const\nconst UP = new Vector3(0, 1, 0)\n\n// The helix runs along x, from the 5′ end of the drawn strand on the left.\nfunction backbonePoint(index: number, count: number, strand: 0 | 1) {\n  const angle = index * TWIST + (strand ? MINOR_GROOVE : 0)\n  return new Vector3(\n    (index - (count - 1) / 2) * RISE,\n    RADIUS * Math.cos(angle),\n    RADIUS * Math.sin(angle)\n  )\n}\n\nfunction buildHelix(pairs: BasePair[]) {\n  const count = pairs.length\n  const group = new Group()\n  const material = () =>\n    new MeshStandardMaterial({ roughness: 0.5, metalness: 0 })\n  const strands = ([0, 1] as const).map((strand) => {\n    const points = pairs.map(({ index }) => backbonePoint(index, count, strand))\n    // A single pair still gets a short backbone stub.\n    if (points.length === 1)\n      points.push(points[0]!.clone().add(new Vector3(RISE, 0, 0)))\n    const curve = new CatmullRomCurve3(points, false, \"centripetal\")\n    const tube = new Mesh(\n      new TubeGeometry(curve, Math.max(8, points.length * 8), 0.9, 10),\n      material()\n    )\n    group.add(tube)\n    return tube\n  })\n\n  const phosphates = new InstancedMesh(\n    new SphereGeometry(1.6, 16, 12),\n    material(),\n    count * 2\n  )\n  const bases = new InstancedMesh(\n    new CylinderGeometry(1, 1, 1, 12),\n    material(),\n    count * 2\n  )\n  const matrix = new Matrix4()\n  const quaternion = new Quaternion()\n  const scale = new Vector3()\n  const direction = new Vector3()\n  const color = new Color()\n  for (const { index } of pairs) {\n    const ends = [\n      backbonePoint(index, count, 0),\n      backbonePoint(index, count, 1),\n    ]\n    const middle = ends[0]!.clone().add(ends[1]!).multiplyScalar(0.5)\n    ends.forEach((end, strand) => {\n      const instance = index * 2 + strand\n      matrix.makeTranslation(end.x, end.y, end.z)\n      phosphates.setMatrixAt(instance, matrix)\n      phosphates.setColorAt(instance, color)\n      // Each base reaches from its backbone to just short of the pair's\n      // centre, so the gap reads as the hydrogen bonds.\n      direction.subVectors(middle, end)\n      const length = direction.length() - 0.35\n      direction.normalize()\n      matrix.compose(\n        end.clone().addScaledVector(direction, length / 2),\n        quaternion.setFromUnitVectors(UP, direction),\n        scale.set(0.85, length, 0.85)\n      )\n      bases.setMatrixAt(instance, matrix)\n      bases.setColorAt(instance, color)\n    })\n  }\n  phosphates.computeBoundingSphere()\n  bases.computeBoundingSphere()\n  group.add(phosphates, bases)\n\n  return {\n    group,\n    pickable: [bases, phosphates],\n    paint(selected: number) {\n      const tone = (hex: string, pair: number) => {\n        color.set(hex)\n        return selected >= 0 && pair !== selected\n          ? color.lerp(STAGE_TONE, 0.65)\n          : color\n      }\n      strands.forEach((tube, strand) => {\n        const material = tube.material as MeshStandardMaterial\n        material.color.set(STRAND_COLORS[strand]!)\n        if (selected >= 0) material.color.lerp(STAGE_TONE, 0.65)\n      })\n      for (const pair of pairs)\n        for (const strand of [0, 1] as const) {\n          const instance = pair.index * 2 + strand\n          phosphates.setColorAt(\n            instance,\n            tone(STRAND_COLORS[strand]!, pair.index)\n          )\n          bases.setColorAt(\n            instance,\n            tone(\n              nucleobaseColors[strand ? pair.partner : pair.base],\n              pair.index\n            )\n          )\n        }\n      phosphates.instanceColor!.needsUpdate = true\n      bases.instanceColor!.needsUpdate = true\n    },\n    dispose() {\n      for (const mesh of [...strands, phosphates, bases]) {\n        mesh.geometry.dispose()\n        ;(mesh.material as MeshStandardMaterial).dispose()\n      }\n      phosphates.dispose()\n      bases.dispose()\n    },\n  }\n}\n\ntype Engine = DnaHelixHandle & {\n  paint: (selected: number) => void\n  spin: (on: boolean) => void\n}\n\nconst iconProps = {\n  width: 16,\n  height: 16,\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n}\n\nfunction ToolbarButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string\n  onClick: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            aria-label={label}\n            onClick={onClick}\n          />\n        }\n      >\n        {children}\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\nfunction Strands({\n  pairs,\n  selected,\n  onSelect,\n}: {\n  pairs: BasePair[]\n  selected: number\n  onSelect: (index: number) => void\n}) {\n  const strip = React.useRef<HTMLDivElement>(null)\n  const [focus, setFocus] = React.useState<number | null>(null)\n  const stop =\n    focus !== null && focus < pairs.length\n      ? focus\n      : selected >= 0 && selected < pairs.length\n        ? selected\n        : 0\n\n  // A pair picked on the stage scrolls into view inside the strip only.\n  React.useEffect(() => {\n    const container = strip.current\n    const button = container?.querySelector<HTMLElement>(\n      `[data-pair=\"${selected}\"]`\n    )\n    if (!container || !button) return\n    const left = button.offsetLeft - container.offsetLeft\n    if (\n      left < container.scrollLeft ||\n      left + button.offsetWidth > container.scrollLeft + container.clientWidth\n    )\n      container.scrollLeft = left - container.clientWidth / 2\n  }, [selected])\n\n  return (\n    <div\n      ref={strip}\n      role=\"toolbar\"\n      aria-label=\"Base pairs\"\n      className=\"flex shrink-0 items-stretch gap-px overflow-x-auto border-t bg-background/60 px-2 py-1.5 font-mono text-xs\"\n      onKeyDown={(event) => {\n        const next: Record<string, number> = {\n          ArrowRight: Math.min(pairs.length - 1, stop + 1),\n          ArrowLeft: Math.max(0, stop - 1),\n          Home: 0,\n          End: pairs.length - 1,\n        }\n        const target = next[event.key]\n        if (target === undefined) return\n        event.preventDefault()\n        setFocus(target)\n        strip.current\n          ?.querySelector<HTMLElement>(`[data-pair=\"${target}\"]`)\n          ?.focus()\n      }}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"flex flex-col justify-around pr-1 text-muted-foreground\"\n      >\n        <span>5′</span>\n        <span>3′</span>\n      </span>\n      {pairs.map((pair) => (\n        <button\n          key={pair.index}\n          type=\"button\"\n          data-pair={pair.index}\n          tabIndex={pair.index === stop ? 0 : -1}\n          aria-pressed={pair.index === selected}\n          aria-label={`Pair ${pair.index + 1}: ${pair.base}–${pair.partner}`}\n          onFocus={() => setFocus(pair.index)}\n          onClick={() => onSelect(pair.index)}\n          className={cn(\n            \"flex min-w-5 flex-col items-center rounded px-0.5 py-0.5 leading-tight hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring\",\n            pair.index === selected &&\n              \"bg-primary text-primary-foreground hover:bg-primary\"\n          )}\n        >\n          {[pair.base, pair.partner].map((base, strand) => (\n            // The space keeps the bases separate words in the visible label,\n            // so voice control matches them against the accessible name.\n            <React.Fragment key={strand}>\n              {strand > 0 && \" \"}\n              <span className=\"flex items-center gap-0.5\">\n                <span\n                  aria-hidden=\"true\"\n                  className=\"size-1.5 rounded-full\"\n                  style={{ backgroundColor: nucleobaseColors[base] }}\n                />\n                {base}\n              </span>\n            </React.Fragment>\n          ))}\n        </button>\n      ))}\n      <span\n        aria-hidden=\"true\"\n        className=\"flex flex-col justify-around pl-1 text-muted-foreground\"\n      >\n        <span>3′</span>\n        <span>5′</span>\n      </span>\n    </div>\n  )\n}\n\nexport function DnaHelix({\n  sequence = \"ATGCGTACCTGAGCTTAACGGATC\",\n  selectedIndex = null,\n  onSelectionChange,\n  autoRotate = true,\n  strands = true,\n  controls = true,\n  label = \"Interactive DNA double helix\",\n  className,\n  ref,\n}: DnaHelixProps) {\n  const host = React.useRef<HTMLDivElement>(null)\n  const parsed = React.useMemo(() => {\n    try {\n      const pairs = dnaBasePairs(sequence)\n      return pairs.length ? { pairs } : { error: \"The sequence is empty.\" }\n    } catch (reason) {\n      return {\n        error:\n          reason instanceof Error ? reason.message : \"The sequence is invalid.\",\n      }\n    }\n  }, [sequence])\n  const pairs = parsed.pairs ?? null\n  const [graphicsFailed, setGraphicsFailed] = React.useState(false)\n  const [engine, setEngine] = React.useState<Engine | null>(null)\n  const [interacted, setInteracted] = React.useState(false)\n  const [paused, setPaused] = React.useState(false)\n  const [reducedMotion, setReducedMotion] = React.useState(false)\n  const spinning = autoRotate && !paused && !reducedMotion\n  const instructions = React.useId()\n  const latest = React.useRef({ onSelectionChange, pairs })\n  React.useLayoutEffect(() => {\n    latest.current = { onSelectionChange, pairs }\n  })\n  const selected =\n    pairs && selectedIndex !== null && selectedIndex < pairs.length\n      ? selectedIndex\n      : -1\n  const select = React.useCallback((index: number) => {\n    const pair = latest.current.pairs?.[index]\n    if (pair) latest.current.onSelectionChange?.(index, pair)\n  }, [])\n\n  React.useEffect(() => {\n    const motion = matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const apply = () => setReducedMotion(motion.matches)\n    apply()\n    motion.addEventListener(\"change\", apply)\n    return () => motion.removeEventListener(\"change\", apply)\n  }, [])\n\n  React.useEffect(() => {\n    const container = host.current\n    if (!pairs || !container || graphicsFailed) return\n    const canvas = document.createElement(\"canvas\")\n    canvas.className = \"block size-full touch-none outline-none\"\n    canvas.setAttribute(\"aria-hidden\", \"true\")\n    container.prepend(canvas)\n    let renderer: WebGLRenderer\n    try {\n      renderer = new WebGLRenderer({\n        canvas,\n        antialias: true,\n        alpha: true,\n        powerPreference: \"low-power\",\n      })\n    } catch {\n      canvas.remove()\n      // WebGL is an external system; its failure is reported once per attempt.\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setGraphicsFailed(true)\n      return\n    }\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))\n    const stage = new Scene()\n    stage.add(new AmbientLight(\"#ffffff\", 0.8))\n    stage.add(new HemisphereLight(\"#fffcf3\", \"#a49b8f\", 1.4))\n    const camera = new PerspectiveCamera(34, 1, 0.5, 5000)\n    const lights: [number[], number, string][] = [\n      [[-4, 6, 6], 2.4, \"#fff5e6\"],\n      [[5, 1, -4], 1.4, \"#e1edff\"],\n    ]\n    for (const [position, intensity, color] of lights) {\n      const light = new DirectionalLight(color, intensity)\n      light.position.set(position[0]!, position[1]!, position[2]!)\n      camera.add(light)\n    }\n    stage.add(camera)\n    const helix = buildHelix(pairs)\n    stage.add(helix.group)\n    const orbit = new OrbitControls(camera, canvas)\n    orbit.enablePan = false\n    orbit.rotateSpeed = 0.65\n    orbit.zoomSpeed = 0.8\n    orbit.enableDamping = !matchMedia(\"(prefers-reduced-motion: reduce)\")\n      .matches\n    const length = pairs.length * RISE\n    orbit.minDistance = RADIUS * 2\n    orbit.maxDistance = Math.max(length, RADIUS * 4) * 4\n\n    let dirty = true\n    const render = () => (dirty = true)\n    let spin = false\n    let last = performance.now()\n    let frame = 0\n    const tick = (now: number) => {\n      if (spin) {\n        // About one turn every 25 seconds.\n        helix.group.rotation.x += Math.min(0.1, (now - last) / 1000) * 0.25\n        dirty = true\n      }\n      last = now\n      if (orbit.update() || dirty) {\n        renderer.render(stage, camera)\n        dirty = false\n      }\n      frame = requestAnimationFrame(tick)\n    }\n\n    const reset = () => {\n      orbit.reset()\n      orbit.target.set(0, 0, 0)\n      const halfV = (camera.fov * Math.PI) / 360\n      const tanV = Math.tan(halfV)\n      const tanH = tanV * camera.aspect\n      const distance =\n        Math.max((length / 2 + RADIUS) / tanH, (RADIUS + 2) / tanV) / 0.85 +\n        RADIUS\n      camera.position.set(0, 0, Math.min(orbit.maxDistance, distance))\n      camera.lookAt(0, 0, 0)\n      helix.group.rotation.set(0, 0, 0)\n      orbit.update()\n      render()\n    }\n    const resize = () => {\n      const width = container.clientWidth\n      const height = container.clientHeight\n      if (!width || !height) return\n      renderer.setSize(width, height, false)\n      camera.aspect = width / height\n      camera.updateProjectionMatrix()\n      render()\n    }\n    const observer = new ResizeObserver(resize)\n    observer.observe(container)\n    resize()\n    camera.aspect = container.clientWidth / Math.max(1, container.clientHeight)\n    reset()\n\n    let interacting = false\n    const onStart = () => {\n      interacting = true\n      // Moving the camera stops the spin, as the pause button would.\n      setPaused(true)\n    }\n    const onEnd = () => (interacting = false)\n    const onChange = () => {\n      if (interacting) setInteracted(true)\n      render()\n    }\n    orbit.addEventListener(\"start\", onStart)\n    orbit.addEventListener(\"end\", onEnd)\n    orbit.addEventListener(\"change\", onChange)\n\n    const raycaster = new Raycaster()\n    const down = new Vector2()\n    const onPointerDown = (event: PointerEvent) =>\n      down.set(event.clientX, event.clientY)\n    const onPointerUp = (event: PointerEvent) => {\n      if (down.distanceTo(new Vector2(event.clientX, event.clientY)) > 4) return\n      const bounds = canvas.getBoundingClientRect()\n      raycaster.setFromCamera(\n        new Vector2(\n          ((event.clientX - bounds.left) / bounds.width) * 2 - 1,\n          -((event.clientY - bounds.top) / bounds.height) * 2 + 1\n        ),\n        camera\n      )\n      const hit = raycaster.intersectObjects(helix.pickable, false)[0]\n      if (hit?.instanceId !== undefined) select(Math.floor(hit.instanceId / 2))\n    }\n    canvas.addEventListener(\"pointerdown\", onPointerDown)\n    canvas.addEventListener(\"pointerup\", onPointerUp)\n    const onContextLost = (event: Event) => {\n      event.preventDefault()\n      setGraphicsFailed(true)\n    }\n    canvas.addEventListener(\"webglcontextlost\", onContextLost)\n\n    setEngine({\n      paint(selected) {\n        helix.paint(selected)\n        render()\n      },\n      spin(on) {\n        spin = on\n        last = performance.now()\n      },\n      reset,\n      zoom(factor) {\n        const offset = camera.position.clone().sub(orbit.target)\n        offset.setLength(\n          Math.min(\n            orbit.maxDistance,\n            Math.max(orbit.minDistance, offset.length() * factor)\n          )\n        )\n        camera.position.copy(orbit.target).add(offset)\n        orbit.update()\n        setInteracted(true)\n        render()\n      },\n      rotate(azimuth, polar = 0) {\n        const spherical = new Spherical().setFromVector3(\n          camera.position.clone().sub(orbit.target)\n        )\n        spherical.theta += azimuth\n        spherical.phi += polar\n        spherical.makeSafe()\n        camera.position\n          .copy(orbit.target)\n          .add(new Vector3().setFromSpherical(spherical))\n        orbit.update()\n        setPaused(true)\n        setInteracted(true)\n        render()\n      },\n    })\n    frame = requestAnimationFrame(tick)\n\n    return () => {\n      cancelAnimationFrame(frame)\n      setEngine(null)\n      observer.disconnect()\n      canvas.removeEventListener(\"pointerdown\", onPointerDown)\n      canvas.removeEventListener(\"pointerup\", onPointerUp)\n      canvas.removeEventListener(\"webglcontextlost\", onContextLost)\n      orbit.dispose()\n      stage.remove(helix.group)\n      helix.dispose()\n      renderer.dispose()\n      canvas.remove()\n    }\n  }, [pairs, graphicsFailed, select])\n\n  React.useEffect(() => {\n    engine?.paint(selected)\n  }, [engine, selected])\n\n  React.useEffect(() => {\n    engine?.spin(spinning)\n  }, [engine, spinning])\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      zoom: (factor) => engine?.zoom(factor),\n      rotate: (azimuth, polar) => engine?.rotate(azimuth, polar),\n      reset: () => engine?.reset(),\n    }),\n    [engine]\n  )\n\n  const message = parsed.error\n    ? { title: \"The sequence can’t be drawn\", text: parsed.error, retry: false }\n    : graphicsFailed\n      ? {\n          title: \"3D view unavailable\",\n          text: \"WebGL could not start or its connection was interrupted. Try again, or use a browser with hardware acceleration enabled.\",\n          retry: true,\n        }\n      : null\n  const pair = pairs && selected >= 0 ? pairs[selected]! : null\n  const status = pair\n    ? `Pair ${pair.index + 1} of ${pairs!.length} · ${nucleobaseNames[pair.base]}–${nucleobaseNames[pair.partner].toLowerCase()} · ${pair.hydrogenBonds} hydrogen bonds`\n    : pairs\n      ? `${pairs.length} base pair${pairs.length === 1 ? \"\" : \"s\"}`\n      : \"\"\n\n  return (\n    <div\n      className={cn(\n        \"flex h-96 w-full flex-col overflow-hidden rounded-xl border bg-muted/30 text-sm text-foreground\",\n        className\n      )}\n    >\n      <div\n        ref={host}\n        role=\"region\"\n        aria-label={label}\n        aria-describedby={instructions}\n        tabIndex={0}\n        className=\"relative min-h-0 flex-1 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\"\n        onKeyDown={(event) => {\n          if (event.target !== event.currentTarget || !engine) return\n          const actions: Record<string, () => void> = {\n            ArrowLeft: () => engine.rotate(-0.2),\n            ArrowRight: () => engine.rotate(0.2),\n            ArrowUp: () => engine.rotate(0, -0.2),\n            ArrowDown: () => engine.rotate(0, 0.2),\n            \"+\": () => engine.zoom(0.85),\n            \"=\": () => engine.zoom(0.85),\n            \"-\": () => engine.zoom(1.18),\n          }\n          const action = actions[event.key]\n          if (!action) return\n          event.preventDefault()\n          action()\n        }}\n      >\n        <p id={instructions} className=\"sr-only\">\n          Drag or use the arrow keys to rotate, and scroll, pinch or press plus\n          and minus to zoom. Click a base pair to select it, or choose one from\n          the strands.\n        </p>\n        {message && (\n          <div className=\"absolute inset-0 flex items-center justify-center p-6\">\n            <Alert className=\"max-w-sm\">\n              <svg {...iconProps}>\n                <circle cx=\"12\" cy=\"12\" r=\"10\" />\n                <path d=\"M12 8v4M12 16h.01\" />\n              </svg>\n              <AlertTitle>{message.title}</AlertTitle>\n              <AlertDescription>\n                <p>{message.text}</p>\n                {message.retry && (\n                  <Button\n                    variant=\"outline\"\n                    size=\"sm\"\n                    onClick={() => setGraphicsFailed(false)}\n                    className=\"mt-3 text-foreground\"\n                  >\n                    Try again\n                  </Button>\n                )}\n              </AlertDescription>\n            </Alert>\n          </div>\n        )}\n        {engine && controls && (\n          <TooltipProvider>\n            <div\n              role=\"toolbar\"\n              aria-label=\"View controls\"\n              className=\"absolute top-2 right-2 flex items-center gap-0.5 rounded-lg border bg-background/90 p-1 shadow-sm backdrop-blur\"\n            >\n              {autoRotate && !reducedMotion && (\n                <ToolbarButton\n                  label={paused ? \"Resume rotation\" : \"Pause rotation\"}\n                  onClick={() => setPaused(!paused)}\n                >\n                  {paused ? (\n                    <svg {...iconProps}>\n                      <path d=\"M7 4v16l13-8z\" />\n                    </svg>\n                  ) : (\n                    <svg {...iconProps}>\n                      <path d=\"M8 5v14M16 5v14\" />\n                    </svg>\n                  )}\n                </ToolbarButton>\n              )}\n              <ToolbarButton label=\"Zoom in\" onClick={() => engine.zoom(0.85)}>\n                <svg {...iconProps}>\n                  <path d=\"M5 12h14M12 5v14\" />\n                </svg>\n              </ToolbarButton>\n              <ToolbarButton label=\"Zoom out\" onClick={() => engine.zoom(1.18)}>\n                <svg {...iconProps}>\n                  <path d=\"M5 12h14\" />\n                </svg>\n              </ToolbarButton>\n              <ToolbarButton label=\"Reset view\" onClick={() => engine.reset()}>\n                <svg {...iconProps}>\n                  <path d=\"M3 12a9 9 0 1 0 3-6.7L3 8\" />\n                  <path d=\"M3 3v5h5\" />\n                </svg>\n              </ToolbarButton>\n            </div>\n          </TooltipProvider>\n        )}\n        {engine && !interacted && (\n          <p\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-x-0 bottom-8 text-center text-xs text-muted-foreground\"\n          >\n            Drag to rotate · Scroll or pinch to zoom\n          </p>\n        )}\n        <p\n          aria-live=\"polite\"\n          className=\"pointer-events-none absolute right-2 bottom-1.5 left-2 truncate text-[11px] text-muted-foreground\"\n        >\n          {status}\n        </p>\n      </div>\n      {strands && pairs && (\n        <Strands pairs={pairs} selected={selected} onSelect={select} />\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "DNA helix docs: https://organui.com/docs/components/dna-helix. Needs one of shadcn's Base UI styles, such as base-nova: its controls use Base UI's render prop, so on a Radix style its buttons do nothing.",
  "categories": [
    "molecular"
  ],
  "type": "registry:component"
}