# Scroll Area

> Thin neon scrollbar with edge fades that appear only when needed.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/scroll-area

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/scroll-area.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/scroll-area`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ScrollArea } from "@/components/ui/scroll-area"

<ScrollArea aria-label="Changelog" className="h-80 rounded-xl border border-white/10">
  …
</ScrollArea>
```

## Examples

### Changelog

Give the root a height or max-height. Fades appear only on edges with more content past them and grow as you scroll away from an edge.

```tsx
import { ScrollArea } from "@/components/ui/scroll-area";

const releases = [
  {
    version: "2.3.0",
    date: "Oct 8, 2026",
    notes: [
      "New Table with sortable headers, row selection and a sticky header.",
      "Timeline lights its tube up to the current event.",
      "Scroll Area with edge fades that only appear when there is more to see.",
    ],
  },
  {
    version: "2.2.4",
    date: "Sep 22, 2026",
    notes: ["Select no longer scrolls the page when opened near the bottom of the viewport."],
  },
  {
    version: "2.2.3",
    date: "Sep 9, 2026",
    notes: [
      "Dialog returns focus to the trigger after a nested Select closes.",
      "Badge dots line up with the text baseline at the small size.",
    ],
  },
  {
    version: "2.2.0",
    date: "Aug 12, 2026",
    notes: [
      "Overlays are positioned with floating-ui and flip when they run out of room.",
      "Every component reads color through --neon, so one variable themes a whole subtree.",
    ],
  },
  {
    version: "2.1.2",
    date: "Jul 1, 2026",
    notes: ["Switch flickers on only when it is turned on, not on first render."],
  },
  {
    version: "2.1.0",
    date: "May 20, 2026",
    notes: [
      "Select, Combobox and Input share one field anatomy: label, description and error.",
      "Sizes line up at 32, 40 and 48 pixels across every form control.",
    ],
  },
  {
    version: "2.0.0",
    date: "Mar 4, 2026",
    notes: [
      "Tailwind 4 support. Components still work unchanged on Tailwind 3.",
      "New palette with eleven colors stored as RGB channels.",
    ],
  },
];

export default function ScrollAreaDemo() {
  return (
    <ScrollArea
      aria-label="Changelog"
      className="h-80 w-full max-w-sm rounded-xl border border-white/10 bg-white/[0.02]"
    >
      <ol className="grid gap-6 p-5 pr-6">
        {releases.map((release) => (
          <li key={release.version} className="grid gap-2">
            <div className="flex items-baseline justify-between gap-3">
              <span className="font-mono text-sm font-medium text-white/90">v{release.version}</span>
              <span className="text-xs text-white/40">{release.date}</span>
            </div>
            <ul className="grid gap-1.5">
              {release.notes.map((note) => (
                <li key={note} className="flex gap-2.5 text-sm leading-6 text-white/60">
                  <span aria-hidden className="mt-[11px] size-1 shrink-0 rounded-full bg-white/25" />
                  {note}
                </li>
              ))}
            </ul>
          </li>
        ))}
      </ol>
    </ScrollArea>
  );
}
```

### Horizontal

The bar overlays the content, so leave a little padding under it. Wheel input over the bar scrolls sideways.

```tsx
import { ScrollArea } from "@/components/ui/scroll-area";

const topics = [
  "accessibility",
  "animation",
  "design-tokens",
  "forms",
  "keyboard",
  "motion",
  "overlays",
  "performance",
  "react-19",
  "server-components",
  "tailwind-4",
  "theming",
  "typography",
];

export default function ScrollAreaTags() {
  return (
    <article className="w-full max-w-md rounded-xl border border-white/10 bg-white/[0.02] p-4">
      <div className="flex items-baseline justify-between gap-3">
        <h3 className="truncate text-sm font-medium text-white/90">halcyon/ui</h3>
        <span className="shrink-0 text-xs tabular-nums text-white/50">2.4k stars</span>
      </div>
      <p className="mt-1 text-[13px] leading-5 text-white/55">
        React components for the Halcyon dashboard, published from the monorepo on every tag.
      </p>
      <ScrollArea orientation="horizontal" aria-label="Topics" className="mt-4">
        <ul className="flex w-max gap-2 pb-3">
          {topics.map((topic) => (
            <li
              key={topic}
              className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 font-mono text-xs text-white/70"
            >
              {topic}
            </li>
          ))}
        </ul>
      </ScrollArea>
    </article>
  );
}
```

### Both axes

Long lines scroll sideways while the log scrolls down.

```tsx
import { ScrollArea } from "@/components/ui/scroll-area";

const log = `14:02:11.204  Cloning github.com/halcyon/web (branch: main, commit: 8f3a2c1)
14:02:12.880  Restored build cache from previous deployment (1.4 GB) in 2.1s
14:02:13.016  Running "npm ci --prefer-offline --no-audit --fund=false"
14:02:21.442  added 1,204 packages in 8s
14:02:21.590  Running "next build"
14:02:23.118  ▲ Next.js 16.4.0  ·  Turbopack  ·  experimental: ppr, reactCompiler
14:02:35.731  ✓ Compiled successfully in 12.6s
14:02:35.902  Linting and checking validity of types ...
14:02:44.207  ✓ Generating static pages (312/312)
14:02:44.560  Route (app)                                   Size     First Load JS
14:02:44.561  ┌ ○ /                                         6.2 kB          118 kB
14:02:44.561  ├ ○ /components/[slug]                        9.8 kB          131 kB
14:02:44.562  ├ ƒ /api/registry/[name]                      0 B             102 kB
14:02:44.562  └ ○ /docs/theming                             3.1 kB          115 kB
14:02:45.010  Collected 18 serverless functions (largest: api/registry/[name], 1.9 MB)
14:02:46.333  Uploading build outputs to fra1, iad1, hnd1 ...
14:02:59.020  Deployment ready in 47.8s → https://web-8f3a2c1-halcyon.neon.app`;

export default function ScrollAreaBuildLog() {
  return (
    <ScrollArea
      orientation="both"
      scrollbar="auto"
      aria-label="Build log"
      className="h-64 w-full max-w-xl rounded-xl border border-white/10 bg-[#07080c]"
    >
      <pre className="p-4 pb-5 pr-5 font-mono text-xs leading-6 text-white/65">{log}</pre>
    </ScrollArea>
  );
}
```

### Scrollbar visibility

```tsx
import { ScrollArea } from "@/components/ui/scroll-area";

const regions = [
  "Frankfurt · fra1",
  "London · lhr1",
  "Paris · cdg1",
  "Stockholm · arn1",
  "Washington, D.C. · iad1",
  "Cleveland · cle1",
  "San Francisco · sfo1",
  "Portland · pdx1",
  "São Paulo · gru1",
  "Tokyo · hnd1",
  "Seoul · icn1",
  "Mumbai · bom1",
  "Singapore · sin1",
  "Sydney · syd1",
];

const modes = [
  { mode: "hover", note: "While hovered or scrolling" },
  { mode: "auto", note: "Whenever it overflows" },
  { mode: "always", note: "Track always shown" },
] as const;

export default function ScrollAreaScrollbarModes() {
  return (
    <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-3">
      {modes.map(({ mode, note }) => (
        <div key={mode} className="grid gap-2">
          <div className="grid">
            <span className="font-mono text-xs text-white/75">scrollbar=&quot;{mode}&quot;</span>
            <span className="text-xs text-white/40">{note}</span>
          </div>
          <ScrollArea
            scrollbar={mode}
            aria-label={`Regions, scrollbar ${mode}`}
            className="h-48 rounded-xl border border-white/10 bg-white/[0.02]"
          >
            <ul className="grid py-2 pr-3">
              {regions.map((region) => (
                <li key={region} className="px-3 py-1.5 text-sm text-white/65">
                  {region}
                </li>
              ))}
            </ul>
          </ScrollArea>
        </div>
      ))}
    </div>
  );
}
```

## API reference

### ScrollArea

Also accepts native div attributes, which go to the root along with ref. Size the root with className.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"vertical" \| "horizontal" \| "both"` | `"vertical"` | Which axes scroll. |
| `scrollbar` | `"hover" \| "always" \| "auto"` | `"hover"` | hover: while the pointer is over it or it's scrolling. auto: whenever the content overflows. always: the track stays visible even without overflow. |
| `fade` | `boolean \| number` | `true` | Mask the edges that have more content past them. A number sets the fade length in pixels (default 24). |
| `viewportClassName` | `string` | — | Classes for the scrolling viewport. |
| `viewportRef` | `Ref<HTMLDivElement>` | — | The scrolling element, for scrollTo() and scroll position. |
| `onScroll` | `UIEventHandler<HTMLDivElement>` | — | Fires when the viewport scrolls. |
| `aria-label` | `string` | — | Names the viewport and gives it role="region". aria-labelledby works the same way. |
| `color` | `NeonColor` | `inherited` | Thumb glow on hover and drag. Falls back to --neon-accent, then cyan. |

## Accessibility

- Scrolling stays native: the viewport is a real overflow container, so wheel, trackpad, touch and keyboard scrolling behave exactly as the browser does.
- When the content overflows and has nothing focusable inside, the viewport joins the tab order so keyboard users can scroll it with the arrow keys, Page Up and Page Down, Home and End.
- The custom thumb is hidden from assistive tech, as native scrollbars are. Drag it, or press the track to jump there and keep dragging.
- Pass aria-label when the content needs a name, such as a changelog or a log viewer. The viewport becomes a named region.

## Source

`components/ui/scroll-area.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Orientation = "vertical" | "horizontal" | "both";
type Axis = "x" | "y";

const DEFAULT_FADE = 24;
const MIN_THUMB = 20;
/** Space between the thumb's travel and the ends of the track. */
const TRACK_PAD = 2;
/** How long the scrollbar lingers after the last scroll event in hover mode. */
const LINGER = 900;

const FOCUSABLE =
  'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';

const fadeY =
  "linear-gradient(to bottom, transparent, #000 var(--nk-fade-top, 0px), #000 calc(100% - var(--nk-fade-bottom, 0px)), transparent)";
const fadeX =
  "linear-gradient(to right, transparent, #000 var(--nk-fade-left, 0px), #000 calc(100% - var(--nk-fade-right, 0px)), transparent)";

function maskFor(orientation: Orientation): React.CSSProperties {
  if (orientation === "vertical") return { maskImage: fadeY, WebkitMaskImage: fadeY };
  if (orientation === "horizontal") return { maskImage: fadeX, WebkitMaskImage: fadeX };
  return {
    maskImage: `${fadeY}, ${fadeX}`,
    WebkitMaskImage: `${fadeY}, ${fadeX}`,
    maskComposite: "intersect",
    WebkitMaskComposite: "source-in",
  };
}

function setRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}

const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

export interface ScrollAreaProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "onScroll"> {
  /** Palette name or any hex color for the thumb glow. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Which axes scroll. */
  orientation?: Orientation;
  /**
   * "hover" shows the scrollbar while the pointer is over the area or it is scrolling,
   * "auto" whenever the content overflows, "always" keeps the track visible even without overflow.
   */
  scrollbar?: "hover" | "always" | "auto";
  /** Fade the edges that have more content past them. A number sets the fade length in px. */
  fade?: boolean | number;
  /** Classes for the scrolling viewport inside the root. */
  viewportClassName?: string;
  /** Ref to the scrolling viewport, for scrollTo() and friends. The root takes `ref`. */
  viewportRef?: React.Ref<HTMLDivElement>;
  /** Fires when the viewport scrolls. */
  onScroll?: React.UIEventHandler<HTMLDivElement>;
}

const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
  (
    {
      className,
      style,
      color,
      orientation = "vertical",
      scrollbar = "hover",
      fade = true,
      viewportClassName,
      viewportRef: viewportRefProp,
      onScroll,
      onPointerEnter,
      onPointerLeave,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledBy,
      children,
      ...props
    },
    ref,
  ) => {
    const axisY = orientation !== "horizontal";
    const axisX = orientation !== "vertical";
    const fadeSize = fade === true ? DEFAULT_FADE : typeof fade === "number" ? Math.max(0, fade) : 0;

    const viewportRef = React.useRef<HTMLDivElement | null>(null);
    const contentRef = React.useRef<HTMLDivElement>(null);
    const trackRefs = React.useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });
    const thumbRefs = React.useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });
    const drag = React.useRef<{ axis: Axis; offset: number } | null>(null);
    const scrollTimer = React.useRef(0);
    const scrollingRef = React.useRef(false);
    const rtlRef = React.useRef(false);

    const [overflow, setOverflow] = React.useState({ x: false, y: false });
    const [focusableContent, setFocusableContent] = React.useState(false);
    const [hovered, setHovered] = React.useState(false);
    const [scrolling, setScrolling] = React.useState(false);
    const [dragging, setDragging] = React.useState<Axis | null>(null);
    const [thumbHovered, setThumbHovered] = React.useState<Axis | null>(null);

    const setViewport = React.useCallback(
      (node: HTMLDivElement | null) => {
        viewportRef.current = node;
        setRef(viewportRefProp, node);
      },
      [viewportRefProp],
    );

    /** Positions the thumbs and edge fades. Runs on every scroll, so it never re-renders. */
    const sync = React.useCallback(() => {
      const viewport = viewportRef.current;
      if (!viewport) return;

      const axes: [Axis, boolean][] = [
        ["y", axisY],
        ["x", axisX],
      ];
      for (const [axis, enabled] of axes) {
        if (!enabled) continue;
        const size = axis === "y" ? viewport.clientHeight : viewport.clientWidth;
        const content = axis === "y" ? viewport.scrollHeight : viewport.scrollWidth;
        const max = Math.max(0, content - size);
        // Measured from the top or left edge. Right-to-left scrollLeft runs from 0 down to -max.
        const scrolled = clamp(Math.abs(axis === "y" ? viewport.scrollTop : viewport.scrollLeft), 0, max);
        const position = axis === "x" && rtlRef.current ? max - scrolled : scrolled;

        const track = trackRefs.current[axis];
        const thumb = thumbRefs.current[axis];
        if (track && thumb && content > 0) {
          const trackSize = Math.max(0, (axis === "y" ? track.clientHeight : track.clientWidth) - TRACK_PAD * 2);
          const thumbSize = Math.min(trackSize, Math.max(MIN_THUMB, (trackSize * size) / content));
          const offset = max > 0 ? ((trackSize - thumbSize) * position) / max : 0;
          thumb.style[axis === "y" ? "height" : "width"] = `${thumbSize}px`;
          thumb.style.transform =
            axis === "y" ? `translate3d(0, ${offset}px, 0)` : `translate3d(${offset}px, 0, 0)`;
        }

        if (fadeSize > 0) {
          const [start, end] = axis === "y" ? ["top", "bottom"] : ["left", "right"];
          viewport.style.setProperty(`--nk-fade-${start}`, `${Math.min(position, fadeSize)}px`);
          viewport.style.setProperty(`--nk-fade-${end}`, `${Math.min(max - position, fadeSize)}px`);
        }
      }
    }, [axisX, axisY, fadeSize]);

    /** Re-reads overflow after a size change, then syncs. */
    const measure = React.useCallback(() => {
      const viewport = viewportRef.current;
      if (!viewport) return;
      const x = axisX && viewport.scrollWidth - viewport.clientWidth >= 1;
      const y = axisY && viewport.scrollHeight - viewport.clientHeight >= 1;
      rtlRef.current = getComputedStyle(viewport).direction === "rtl";
      setOverflow((previous) => (previous.x === x && previous.y === y ? previous : { x, y }));
      setFocusableContent(viewport.querySelector(FOCUSABLE) !== null);
      sync();
    }, [axisX, axisY, sync]);

    useIsomorphicLayoutEffect(() => {
      measure();
      const viewport = viewportRef.current;
      const content = contentRef.current;
      if (!viewport || typeof ResizeObserver === "undefined") {
        window.addEventListener("resize", measure);
        return () => window.removeEventListener("resize", measure);
      }
      const observer = new ResizeObserver(() => measure());
      observer.observe(viewport);
      if (content) observer.observe(content);
      return () => observer.disconnect();
    }, [measure]);

    // The bars sit on top of the viewport, so wheel input over them is passed
    // through by hand. At the end of the scroll range it falls back to the page.
    React.useEffect(() => {
      const viewport = viewportRef.current;
      const tracks = [trackRefs.current.x, trackRefs.current.y].filter(
        (track): track is HTMLDivElement => track !== null,
      );
      if (!viewport || tracks.length === 0) return;

      function handleWheel(event: WheelEvent) {
        if (!viewport) return;
        const unit =
          event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
        const top = viewport.scrollTop;
        const left = viewport.scrollLeft;
        if (axisY) viewport.scrollTop += event.deltaY * unit;
        if (axisX) viewport.scrollLeft += (event.deltaX || (axisY ? 0 : event.deltaY)) * unit;
        if (viewport.scrollTop !== top || viewport.scrollLeft !== left) event.preventDefault();
      }

      tracks.forEach((track) => track.addEventListener("wheel", handleWheel, { passive: false }));
      return () => tracks.forEach((track) => track.removeEventListener("wheel", handleWheel));
    }, [axisX, axisY]);

    React.useEffect(() => () => window.clearTimeout(scrollTimer.current), []);

    function handleScroll(event: React.UIEvent<HTMLDivElement>) {
      onScroll?.(event);
      sync();
      if (!scrollingRef.current) {
        scrollingRef.current = true;
        setScrolling(true);
      }
      window.clearTimeout(scrollTimer.current);
      scrollTimer.current = window.setTimeout(() => {
        scrollingRef.current = false;
        setScrolling(false);
      }, LINGER);
    }

    /** Scrolls so the thumb's leading edge sits `offset` px before the pointer. */
    function scrollToPointer(axis: Axis, pointer: number, offset: number) {
      const viewport = viewportRef.current;
      const track = trackRefs.current[axis];
      const thumb = thumbRefs.current[axis];
      if (!viewport || !track || !thumb) return;

      const trackRect = track.getBoundingClientRect();
      const thumbRect = thumb.getBoundingClientRect();
      const trackStart = (axis === "y" ? trackRect.top : trackRect.left) + TRACK_PAD;
      const trackSize = (axis === "y" ? track.clientHeight : track.clientWidth) - TRACK_PAD * 2;
      const thumbSize = axis === "y" ? thumbRect.height : thumbRect.width;
      const travel = trackSize - thumbSize;
      if (travel <= 0) return;

      const ratio = clamp((pointer - offset - trackStart) / travel, 0, 1);
      if (axis === "y") {
        viewport.scrollTop = ratio * (viewport.scrollHeight - viewport.clientHeight);
      } else {
        const max = viewport.scrollWidth - viewport.clientWidth;
        viewport.scrollLeft = rtlRef.current ? -(1 - ratio) * max : ratio * max;
      }
    }

    function handlePointerDown(axis: Axis, event: React.PointerEvent<HTMLDivElement>) {
      const thumb = thumbRefs.current[axis];
      if (event.button !== 0 || !thumb) return;
      // Keeps focus where it is and stops the drag from selecting text.
      event.preventDefault();

      const pointer = axis === "y" ? event.clientY : event.clientX;
      const rect = thumb.getBoundingClientRect();
      let offset: number;
      if (thumb.contains(event.target as Node)) {
        // Grab the thumb where it was pressed, so it doesn't jump under the pointer.
        offset = pointer - (axis === "y" ? rect.top : rect.left);
      } else {
        // A press on the track jumps there, centered, and keeps dragging from there.
        offset = (axis === "y" ? rect.height : rect.width) / 2;
        scrollToPointer(axis, pointer, offset);
      }

      event.currentTarget.setPointerCapture(event.pointerId);
      drag.current = { axis, offset };
      setDragging(axis);
    }

    function handlePointerMove(axis: Axis, event: React.PointerEvent<HTMLDivElement>) {
      if (drag.current?.axis !== axis) return;
      scrollToPointer(axis, axis === "y" ? event.clientY : event.clientX, drag.current.offset);
    }

    function endDrag() {
      if (!drag.current) return;
      drag.current = null;
      setDragging(null);
    }

    const tracksShown = (axis: Axis) =>
      scrollbar === "always" ||
      (overflow[axis] &&
        (scrollbar === "auto" || hovered || scrolling || dragging !== null));

    const named = ariaLabel != null || ariaLabelledBy != null;

    function renderScrollbar(axis: Axis) {
      const vertical = axis === "y";
      const visible = tracksShown(axis);
      const active = dragging === axis;
      const lit = active || thumbHovered === axis;
      const corner = orientation === "both" && overflow.x && overflow.y;

      return (
        <div
          ref={(node) => {
            trackRefs.current[axis] = node;
          }}
          aria-hidden
          data-axis={axis}
          data-state={visible ? "visible" : "hidden"}
          onPointerDown={(event) => handlePointerDown(axis, event)}
          onPointerMove={(event) => handlePointerMove(axis, event)}
          onPointerUp={endDrag}
          onPointerCancel={endDrag}
          onLostPointerCapture={endDrag}
          className={cn(
            "group/track absolute z-10 touch-none select-none",
            "transition-opacity ease-out",
            vertical ? "right-0 top-0 w-3" : "bottom-0 left-0 h-3",
            vertical ? (corner ? "bottom-3" : "bottom-0") : corner ? "right-3" : "right-0",
            visible ? "opacity-100 duration-150" : "pointer-events-none opacity-0 duration-500",
          )}
        >
          <span
            className={cn(
              "absolute rounded-full bg-white/[0.04] transition-opacity duration-150 ease-out",
              vertical ? "inset-y-0.5 right-[3px] w-1.5" : "inset-x-0.5 bottom-[3px] h-1.5",
              scrollbar === "always" || active ? "opacity-100" : "opacity-0 group-hover/track:opacity-100",
            )}
          />
          <div
            ref={(node) => {
              thumbRefs.current[axis] = node;
            }}
            data-state={active ? "dragging" : undefined}
            onPointerEnter={() => setThumbHovered(axis)}
            onPointerLeave={() => setThumbHovered(null)}
            className={cn(
              "absolute will-change-transform",
              vertical ? "inset-x-0 top-0.5" : "inset-y-0 left-0.5",
              !overflow[axis] && "invisible",
            )}
          >
            <span
              className={cn(
                "absolute rounded-full",
                "transition-[transform,scale,background-color,box-shadow] duration-150 ease-out motion-reduce:transition-none",
                vertical ? "inset-y-0 right-[3px] w-1.5 origin-right" : "inset-x-0 bottom-[3px] h-1.5 origin-bottom",
                active &&
                  "bg-[rgb(var(--neon))] shadow-[0_0_4px_rgb(var(--neon)/var(--neon-glow,1)),0_0_14px_rgb(var(--neon)/CALC(0.75*var(--neon-glow,1)))]",
                !active && lit && "bg-[rgb(var(--neon)/0.85)] shadow-[0_0_10px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]",
                !lit &&
                  cn(
                    "bg-white/25 group-hover/track:bg-white/40",
                    vertical
                      ? "scale-x-[0.67] group-hover/track:scale-x-100"
                      : "scale-y-[0.67] group-hover/track:scale-y-100",
                  ),
              )}
            />
          </div>
        </div>
      );
    }

    return (
      <div
        ref={ref}
        data-orientation={orientation}
        style={neonVars(color, style)}
        onPointerEnter={(event) => {
          onPointerEnter?.(event);
          setHovered(true);
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          setHovered(false);
        }}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          // min-w-0 keeps a flex or grid parent from growing to the content's width.
          "relative flex min-w-0 flex-col",
          "[&:has(>[data-nk-scroll-viewport]:focus-visible)]:ring-2 [&:has(>[data-nk-scroll-viewport]:focus-visible)]:ring-[rgb(var(--neon)/0.6)] [&:has(>[data-nk-scroll-viewport]:focus-visible)]:ring-offset-2 [&:has(>[data-nk-scroll-viewport]:focus-visible)]:ring-offset-[#05060a]",
          className,
        )}
        {...props}
      >
        <div
          ref={setViewport}
          data-nk-scroll-viewport=""
          role={named ? "region" : undefined}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          // Keyboard users need a way in when there is nothing focusable inside to scroll to.
          tabIndex={(overflow.x || overflow.y) && !focusableContent ? 0 : undefined}
          onScroll={handleScroll}
          style={fadeSize > 0 ? maskFor(orientation) : undefined}
          className={cn(
            "min-h-0 flex-auto rounded-[inherit] outline-none",
            "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
            orientation === "vertical" && "overflow-y-auto overflow-x-hidden",
            orientation === "horizontal" && "overflow-x-auto overflow-y-hidden",
            orientation === "both" && "overflow-auto",
            viewportClassName,
          )}
        >
          <div ref={contentRef} className={cn(axisX && "table min-w-full")}>
            {children}
          </div>
        </div>
        {axisY && renderScrollbar("y")}
        {axisX && renderScrollbar("x")}
      </div>
    );
  },
);
ScrollArea.displayName = "ScrollArea";

export { ScrollArea };
```
