Skip to Content
react-brackets

Navigation and zoom

Your own round navigation, your own zoom controls, and how pan, zoom and swipe feel.

Nothing the bracket draws to move around is fixed. The round buttons under a phone-sized bracket and the zoom buttons over a zoomable tree are parts you can restyle, rearrange, replace or leave out, and two hooks give you everything they know to build controls of your own.

Round navigation

On narrow containers the bracket shows one round at a time, with Bracket.Pagination underneath. Without children it draws previous, a button per round, and next. Give it children to arrange them your way:

PartWhat it isCustomize
Bracket.PaginationThe nav around the navigation. Draws nothing while the whole tree is shown.className, children
Bracket.PageButtonA button that moves to round index. Shows the round's name.className and children as functions of { round, index, active }
Bracket.PreviousPage, Bracket.NextPageOne round back or on. Disabled at the ends.className and children as functions of { disabled }
Bracket.PageIndicatorA mark that slides under the button of the round being shown.className, style

Pills that follow the round

Quarterfinals

  1. Falcons2
    Cobras3
  2. Lynx4
    Orcas3
  3. Wolves1
    Bison0
  4. Herons1
    Ravens0

Semifinals

  1. Cobras
    Lynx
  2. Wolves
    Herons

Final

  1. TBD
    TBD
<Bracket.Root stage={stage}>
  <Bracket.Viewport>
    <Bracket.Rounds />
    <Bracket.Connectors radius={8} />
  </Bracket.Viewport>

  <Bracket.Pagination className="flex items-center gap-1">
    <Bracket.PreviousPage className="grid size-8 place-items-center rounded-full disabled:opacity-30">
      <ChevronLeft />
    </Bracket.PreviousPage>
    <div className="relative flex flex-1 justify-between">
      <Bracket.PageIndicator className="top-0 bottom-0 rounded-full bg-blue-500/10" />
      {rounds.map((round, index) => (
        <Bracket.PageButton
          key={round.id}
          index={index}
          className={({ active }) => cn('relative rounded-full bg-transparent px-3 py-1.5', active && 'text-blue-600')}
        />
      ))}
    </div>
    <Bracket.NextPage className="grid size-8 place-items-center rounded-full disabled:opacity-30">
      <ChevronRight />
    </Bracket.NextPage>
  </Bracket.Pagination>
</Bracket.Root>

Bracket.PageIndicator measures the page buttons next to it, so it lines up however you lay them out. It moves with your motion timing, and while a swipe is heading to a round it is already on its way there.

Above the bracket, or not at all

<Bracket> puts the navigation under the paged view. To put it above, compose the bracket and place Bracket.Pagination first. The theme gives it a top margin, which a class overrides:

<Bracket.Root stage={stage}>
  <Bracket.Pagination className="mt-0 mb-3" />
  <Bracket.Viewport>
    <Bracket.Rounds />
    <Bracket.Connectors />
  </Bracket.Viewport>
</Bracket.Root>

Leave Bracket.Pagination out and there is no navigation at all; swiping still pages.

Anything else, from usePagination()

usePagination() works anywhere inside Bracket.Root, so the navigation can sit above the bracket, beside it, or in a header of its own. Here a title with progress sits above, and dots sit below:

Round 1 of 3

Quarterfinals

Quarterfinals

  1. Falcons2
    Cobras3
  2. Lynx4
    Orcas3
  3. Wolves1
    Bison0
  4. Herons1
    Ravens0

Semifinals

  1. Cobras
    Lynx
  2. Wolves
    Herons

Final

  1. TBD
    TBD
function RoundHeader() {
  const { rounds, page, previous, next, canPrevious, canNext } = usePagination();
  return (
    <header>
      <p>Round {page + 1} of {rounds.length}</p>
      <h3>{rounds[page]?.name}</h3>
      <button onClick={previous} disabled={!canPrevious} aria-label="Previous round">‹</button>
      <button onClick={next} disabled={!canNext} aria-label="Next round">›</button>
    </header>
  );
}

function Dots() {
  const { rounds, position, setPage } = usePagination();
  return rounds.map((round, index) => (
    <button
      key={round.id}
      aria-label={round.name}
      aria-current={index === position ? 'step' : undefined}
      onClick={() => setPage(index)}
      className={index === position ? 'dot dot-active' : 'dot'}
    />
  ));
}

<Bracket.Root stage={stage}>
  <RoundHeader />
  <Bracket.Viewport>
    <Bracket.Rounds />
    <Bracket.Connectors />
  </Bracket.Viewport>
  <Dots />
</Bracket.Root>

page is the round in focus. position is the round being drawn in focus, which runs ahead of page while a swipe is on its way, so an indicator built on it moves with the finger. paged is false while the whole tree fits, for controls that should hide themselves then.

With the <Bracket> shortcut, renderPagination swaps in your navigation without composing the rest:

<Bracket stage={stage} renderPagination={() => <Dots />} />

To keep the navigation outside the bracket entirely, like tabs in your page header, control the round with page and onPageChange, as in controlling the round.

Zoom controls

With mode="pan-zoom", the bracket draws its zoom controls in the corner: zoom out, the zoom level and zoom in in one pill, and a button to fit it back into view. They dim at the smallest and largest zoom. They are parts too:

PartWhat it is
Bracket.ZoomControlsThe group of controls. Draws the default ones without children.
Bracket.ZoomIn, Bracket.ZoomOutZoom by the zoom.step option around the centre, or by their own step. Disabled at the limits.
Bracket.ZoomResetFits the whole tree back into view. Disabled while it already is.
Bracket.ZoomLevelThe zoom as a percentage, announced as it changes.

Every one takes className and children as functions of { scale, percent, disabled }. They work anywhere inside Bracket.Root, not only over the bracket. To draw them somewhere else, pass controls={false} to Bracket.Viewport so the corner stays empty.

A toolbar with a slider

useZoom() gives you the zoom and every action, for controls the parts don't cover, like a slider or a button that zooms all the way in:

Round 1

  1. Falcons2
    Vipers3
  2. Cobras4
    Hawks3
  3. Lynx1
    Sharks0
  4. Orcas1
    Pumas0
  5. Wolves2
    Moose0
  6. Bison
    Otters
  7. Herons
    Owls
  8. Ravens
    Cranes

Quarterfinals

  1. Vipers
    Cobras
  2. Lynx
    Orcas
  3. Wolves
    TBD
  4. TBD
    TBD

Semifinals

  1. TBD
    TBD
  2. TBD
    TBD

Final

  1. TBD
    TBD
function ZoomToolbar() {
  const zoom = useZoom();
  if (!zoom) return null;
  return (
    <div className="flex items-center gap-2">
      <Bracket.ZoomOut><Minus /></Bracket.ZoomOut>
      <Slider
        min={zoom.minScale}
        max={zoom.maxScale}
        step={0.01}
        value={[zoom.scale]}
        onValueChange={([scale]) => zoom.zoomTo(scale)}
      />
      <Bracket.ZoomIn><Plus /></Bracket.ZoomIn>
      <Bracket.ZoomLevel />
      <button onClick={() => zoom.zoomTo(zoom.maxScale)} disabled={!zoom.canZoomIn}>Zoom in fully</button>
      <Bracket.ZoomReset><Scan /></Bracket.ZoomReset>
    </div>
  );
}

<Bracket.Root stage={stage} compactBelow={0} zoom={{ maxScale: 2.5 }}>
  <ZoomToolbar />
  <Bracket.Viewport mode="pan-zoom" controls={false}>
    <Bracket.Rounds />
    <Bracket.Connectors radius={8} />
  </Bracket.Viewport>
</Bracket.Root>

useZoom() is null while the bracket isn't showing a pan and zoom viewport, as on a phone, where it pages instead. It returns:

Prop

Type

Floating buttons

With the shortcut, renderZoomControls draws your controls after the viewport in place of the default ones. Position them over it with your own styles:

Round 1

  1. Falcons2
    Vipers3
  2. Cobras4
    Hawks3
  3. Lynx1
    Sharks0
  4. Orcas1
    Pumas0
  5. Wolves2
    Moose0
  6. Bison
    Otters
  7. Herons
    Owls
  8. Ravens
    Cranes

Quarterfinals

  1. Vipers
    Cobras
  2. Lynx
    Orcas
  3. Wolves
    TBD
  4. TBD
    TBD

Semifinals

  1. TBD
    TBD
  2. TBD
    TBD

Final

  1. TBD
    TBD
<div className="relative">
  <Bracket
    stage={stage}
    compactBelow={0}
    mode="pan-zoom"
    renderZoomControls={() => (
      <Bracket.ZoomControls className="absolute top-3 right-3 flex flex-col gap-2">
        <Bracket.ZoomIn className="size-10 rounded-full border shadow-md"><Plus /></Bracket.ZoomIn>
        <Bracket.ZoomOut className="size-10 rounded-full border shadow-md"><Minus /></Bracket.ZoomOut>
        <Bracket.ZoomReset className="size-10 rounded-full border shadow-md"><Maximize /></Bracket.ZoomReset>
      </Bracket.ZoomControls>
    )}
  />
</div>

How pan and zoom feel

Every behaviour of the pan and zoom viewport is an option of zoom, on <Bracket> or Bracket.Root. Try a few:

Round 1

  1. Falcons2
    Vipers3
  2. Cobras4
    Hawks3
  3. Lynx1
    Sharks0
  4. Orcas1
    Pumas0
  5. Wolves2
    Moose0
  6. Bison
    Otters
  7. Herons
    Owls
  8. Ravens
    Cranes

Quarterfinals

  1. Vipers
    Cobras
  2. Lynx
    Orcas
  3. Wolves
    TBD
  4. TBD
    TBD

Semifinals

  1. TBD
    TBD
  2. TBD
    TBD

Final

  1. TBD
    TBD
// Hard edges and no glide
<Bracket stage={stage} compactBelow={0} mode="pan-zoom" zoom={{ elasticity: 0, momentum: false }} />

// A long glide and a loose edge
<Bracket stage={stage} compactBelow={0} mode="pan-zoom" zoom={{ elasticity: 1, momentum: { deceleration: 0.999 } }} />

// Starts at full size, centred while smaller than the frame, and a double tap zooms by 1.5
<Bracket stage={stage} compactBelow={0} mode="pan-zoom" zoom={{ initial: 1, align: 'center', doubleTap: 1.5 }} />

By default:

  • Drag to pan. Released at speed, the tree glides on and slows down, and it gives a little past the edges before springing back.
  • Pinch or Ctrl/⌘ + wheel to zoom around the fingers or the pointer. A pinch may go a little past the limits and settles back.
  • Double click or double tap zooms in two times there, and again zooms back out to fit.
  • Wheel without a key pans while there is room, then lets the page scroll.
  • Keyboard: with the viewport focused, + and - zoom, 0 fits, and the arrow keys pan.

Prop

Type

minScale and maxScale on Bracket.Viewport still work, as shorthands for the same options.

How swiping feels

On a phone, the swipe option tunes how the bracket moves between rounds:

// The round in focus takes the whole width, and a short drag is enough
<Bracket stage={stage} swipe={{ focus: 1, commit: 0.1 }} />

// No give at the first and last round
<Bracket stage={stage} swipe={{ elasticity: 0 }} />

Prop

Type

maxSwipeRounds sets how many rounds one flick can cross, and pageHeight how the height changes between rounds. See small screens.

Labels

Every control's accessible name comes from labels: previous, next, rounds, zoomIn, zoomOut and zoomReset. Translate them there, or give a part its own aria-label. See RTL and languages.

On this page