← ui

edge-arrows

ui

‹ and › at the edges of a stage, quiet until the stage is hovered

npx shadcn add @ag/edge-arrows
Already installed? Preview an update
npx shadcn add @ag/edge-arrows --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • Changed: the glass arrow is 32 px, the plain one's size and the icon button's, where it was 36: the two materials differ in face, never in size.
Updated 2026-10-11 · release notes

also called carousel arrows

shelf controls

uses material

used by components, lightbox

the demo above, verbatim

edge-arrows.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
// #region photos
import lake from "@/public/lightbox/lake.jpg"
import peaks from "@/public/lightbox/peaks.jpg"
import shore from "@/public/lightbox/shore.jpg"
import wide from "@/public/lightbox/wide.jpg"
import { EdgeArrows } from "@/registry/base-nova/ui/edge-arrows"

const PHOTOS = [wide, peaks, lake, shore]
// #endregion

export default function EdgeArrowsDemo() {
  const [index, setIndex] = React.useState(0)
  const photo = PHOTOS[index] as (typeof PHOTOS)[number]
  return (
    <div className="space-y-8">
      <Sample
        name="stage"
        glance
        title="a stage with arrows"
        description="Hover the stage and the arrows come forward. At either end the arrow dims and does nothing."
        with="photos"
      >
        <div className="relative aspect-video overflow-hidden rounded-lg bg-foreground/4">
          {/* biome-ignore lint/performance/noImgElement: a plain still on the stage */}
          <img
            src={photo.src}
            alt=""
            className="size-full object-contain"
            draggable={false}
          />
          <EdgeArrows
            onPrev={() => setIndex((i) => i - 1)}
            onNext={() => setIndex((i) => i + 1)}
            hasPrev={index > 0}
            hasNext={index < PHOTOS.length - 1}
          />
        </div>
      </Sample>
      <Sample name="glass" title="in glass" with="photos">
        <div className="relative aspect-video overflow-hidden rounded-lg bg-foreground/4 text-white">
          {/* biome-ignore lint/performance/noImgElement: a plain still on the stage */}
          <img
            src={photo.src}
            alt=""
            className="size-full object-cover"
            draggable={false}
          />
          <EdgeArrows
            material="glass"
            onPrev={() => setIndex((i) => i - 1)}
            onNext={() => setIndex((i) => i + 1)}
            hasPrev={index > 0}
            hasNext={index < PHOTOS.length - 1}
          />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

edge-arrows.tsx
"use client"

// ‹ and › at the left and right edges of a stage, vertically centred, the lightbox's
// arrows. Quiet at rest; `EdgeArrows` lifts them a tier while the pointer is over the
// stage, and one goes to the title tier under the hand. At an end the arrow stays
// focusable and dims, and a press does nothing: a disabled button drops focus to body
// and takes the keyboard with it. The focus ring is the browser's, which shows for
// the keyboard and not for a click. The stage is the arrows' positioned parent. Two
// materials (`material`): plain, a bare glyph; glass, the glyph on a disc of the
// material's glass, its rim catching more light under the hand.

import type * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"

const ARROW =
  "pointer-events-auto absolute top-1/2 grid -translate-y-1/2 cursor-pointer place-items-center border-0 text-inherit opacity-[var(--ag-edge-rest,var(--alpha-meta))] hover:opacity-(--alpha-title) focus-visible:opacity-(--alpha-title) aria-disabled:cursor-default aria-disabled:opacity-[calc(var(--alpha-meta)*0.4)]"
/** 32 px in either material: the icon button's own size (button `icon`). */
const FACE: Record<Material, string> = {
  plain: "size-8 rounded-[8px] bg-transparent",
  glass: `size-8 [--glass-radius:16px] [--glass-moves:opacity_200ms_var(--ease-out)] [--glass-change-duration:200ms] hover:[--glass-rim:1.6]!`,
}
const SIDE = { left: "left-2", right: "right-2" } as const
const PATH = { left: "M10 3L5 8l5 5", right: "M6 3l5 5-5 5" } as const
const LABEL = { left: "previous", right: "next" } as const

/** One arrow, for a stage that names its own (the lightbox labels them from its
 *  action table). `enabled={false}` is the end of the reel. */
export function EdgeArrow({
  side,
  enabled = true,
  onClick,
  material,
  scheme,
  className,
  ...rest
}: {
  side: "left" | "right"
  enabled?: boolean
  onClick: () => void
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's;
   *  arrows over a picture stand in a stage that says dark. */
  scheme?: Scheme
  className?: string
} & Omit<
  React.ButtonHTMLAttributes<HTMLButtonElement>,
  "onClick" | "children"
>) {
  const m = useMaterial(material)
  // Over a picture each arrow is read by the part of it under the arrow.
  const [s, here] = useSurfaceScheme<HTMLButtonElement>(scheme)
  return (
    <button
      ref={here}
      type="button"
      data-slot="edge-arrow"
      data-side={side}
      data-material={m}
      aria-label={LABEL[side]}
      aria-disabled={!enabled || undefined}
      className={cn(
        schemeOf(s),
        m === "glass" && glassVariants({ shape: "surface" }),
        ARROW,
        FACE[m],
        SIDE[side],
        className,
      )}
      onClick={enabled ? onClick : undefined}
      {...rest}
    >
      <svg
        viewBox="0 0 16 16"
        aria-hidden="true"
        className="ag-icon size-4 fill-none stroke-current stroke-[1.5] [stroke-linecap:round] [stroke-linejoin:round]"
      >
        <path d={PATH[side]} />
      </svg>
    </button>
  )
}

/** Both arrows over the stage they sit in. */
export function EdgeArrows({
  onPrev,
  onNext,
  hasPrev,
  hasNext,
  material,
  scheme,
  className,
}: {
  onPrev: () => void
  onNext: () => void
  hasPrev: boolean
  hasNext: boolean
  material?: Material
  scheme?: Scheme
  className?: string
}) {
  return (
    <div
      data-slot="edge-arrows"
      className={cn(
        "pointer-events-none absolute inset-0 [:hover>&]:[--ag-edge-rest:var(--alpha-body)]",
        className,
      )}
    >
      <EdgeArrow
        side="left"
        enabled={hasPrev}
        onClick={onPrev}
        material={material}
        scheme={scheme}
      />
      <EdgeArrow
        side="right"
        enabled={hasNext}
        onClick={onNext}
        material={material}
        scheme={scheme}
      />
    </div>
  )
}