Skip to Content
react-brackets

Navigation and zoom

Your own round tabs, your own zoom buttons, and how swiping, panning and zooming feel.

The round tabs under a phone-sized bracket and the zoom buttons over a zoomable tree are parts, not fixed drawings. You can restyle them, move them, replace them or leave them out, and two hooks, usePagination() and useZoom(), give you everything they know so you can build your own. Everything here works the same way as on the web, with React Native views and Reanimated in place of the DOM.

Round dots that follow a swipe, and zoom buttons above a pinchable tree
The Custom controls example, recorded on an iPhone

The code for both is in the Custom controls example.

Round tabs

Below compactBelow the bracket shows a round at a time, with Bracket.Pagination underneath. Without children it draws a tab for each round, with a pill behind the round in focus that follows your finger during a swipe. To arrange it yourself, compose the bracket from parts and give Bracket.Pagination children:

PartWhat it isCustomize
Bracket.PaginationThe row of tabs. Draws nothing while the whole tree is shown.style as a function of { page }, children
Bracket.PageButtonA tab that moves to round index. Shows the round's name.style and children as functions of { round, index, active }
Bracket.PreviousPage, Bracket.NextPageOne round back or on. Disabled at the ends.style and children as functions of { disabled }
Bracket.PageIndicatorThe pill behind the tab in focus. It slides and resizes between tabs as a swipe moves, on the UI thread.style as a function of { page }
import { Bracket } from '@react-brackets/native';
import { Text } from 'react-native';

<Bracket.Root stage={stage}>
  <Bracket.Viewport />
  <Bracket.Pagination style={{ gap: 4, justifyContent: 'center' }}>
    <Bracket.PreviousPage>{({ disabled }) => <Text style={{ opacity: disabled ? 0.3 : 1 }}>‹</Text>}</Bracket.PreviousPage>
    <Bracket.PageIndicator style={{ backgroundColor: '#dbeafe' }} />
    {stage.groups[0].rounds.map((round, index) => (
      <Bracket.PageButton
        key={round.id}
        index={index}
        style={({ active }) => ({ paddingHorizontal: 12, paddingVertical: 6, opacity: active ? 1 : 0.6 })}
      />
    ))}
    <Bracket.NextPage>{({ disabled }) => <Text style={{ opacity: disabled ? 0.3 : 1 }}>›</Text>}</Bracket.NextPage>
  </Bracket.Pagination>
</Bracket.Root>;

Bracket.PageIndicator measures the Bracket.PageButtons in the same Bracket.Pagination, so it lines up however you space them. Put it before the buttons so it sits behind them.

With children, Bracket.Pagination is a plain row, so long round names can run out of room. The default tabs scroll sideways instead. For a row of tabs that scrolls, keep the default, or wrap your buttons in a horizontal ScrollView.

Bracket.PageButton children replace the round's name. Use a function to draw from its state:

<Bracket.PageButton index={index}>
  {({ round, active }) => (
    <Text style={{ fontWeight: active ? '700' : '400' }}>{round.name.replace('Round ', 'R')}</Text>
  )}
</Bracket.PageButton>

Above the bracket, or not at all

<Bracket> puts the tabs under the paged view. To put them above it, compose the bracket and place Bracket.Pagination first. The default tabs keep a 12 point gap on their top edge, so move it to the bottom:

<Bracket.Root stage={stage}>
  <Bracket.Pagination style={{ paddingTop: 0, paddingBottom: 12 }} />
  <Bracket.Viewport />
</Bracket.Root>

Leave Bracket.Pagination out and there are no tabs at all; swiping still pages. With the shortcut, renderPagination={() => null} does the same.

Dots that follow the swipe

usePagination() works anywhere inside Bracket.Root. Its position is a Reanimated shared value: the round being shown as a fractional number that changes under your finger, 1.4 when a swipe is 40% of the way from the second round to the third. Read it in useAnimatedStyle and your own indicator moves on the UI thread, with no re-renders during the swipe:

import { Bracket, usePagination } from '@react-brackets/native';
import { Pressable, View } from 'react-native';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';

function Dots() {
  const { rounds } = usePagination();
  return (
    <View style={{ flexDirection: 'row', justifyContent: 'center', gap: 6, paddingTop: 16 }}>
      {rounds.map((round, index) => (
        <Dot key={round.id} index={index} />
      ))}
    </View>
  );
}

/** Wide on the round in focus, narrowing as a swipe moves away from it. */
function Dot({ index }: { index: number }) {
  const { position, setPage } = usePagination();
  const style = useAnimatedStyle(() => {
    const near = Math.max(0, 1 - Math.abs(position.value - index));
    return { width: 8 + 20 * near, opacity: 0.35 + 0.65 * near };
  });
  return (
    <Pressable accessibilityRole="button" onPress={() => setPage(index)} hitSlop={8}>
      <Animated.View style={[{ height: 8, borderRadius: 4, backgroundColor: '#2563eb' }, style]} />
    </Pressable>
  );
}

<Bracket.Root stage={stage}>
  <Bracket.Viewport />
  <Dots />
</Bracket.Root>;

Leave Bracket.Pagination out and nothing else is drawn under the bracket.

A header above the bracket

The hook also gives you the round in focus and the actions to move. Here the round's name and arrows sit above the bracket instead of tabs below it:

import { Bracket, usePagination } from '@react-brackets/native';
import { Pressable, Text, View } from 'react-native';

function RoundHeader() {
  const { rounds, page, previous, next, canPrevious, canNext } = usePagination();
  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 12 }}>
      <View>
        <Text style={{ fontSize: 12, color: '#71717a' }}>
          Round {page + 1} of {rounds.length}
        </Text>
        <Text style={{ fontSize: 17, fontWeight: '600' }}>{rounds[page]?.name}</Text>
      </View>
      <View style={{ flexDirection: 'row', gap: 8 }}>
        <Pressable accessibilityRole="button" accessibilityLabel="Previous round" disabled={!canPrevious} onPress={previous}>
          <Text style={{ fontSize: 22, opacity: canPrevious ? 1 : 0.3 }}>‹</Text>
        </Pressable>
        <Pressable accessibilityRole="button" accessibilityLabel="Next round" disabled={!canNext} onPress={next}>
          <Text style={{ fontSize: 22, opacity: canNext ? 1 : 0.3 }}>›</Text>
        </Pressable>
      </View>
    </View>
  );
}

<Bracket.Root stage={stage}>
  <RoundHeader />
  <Bracket.Viewport />
</Bracket.Root>;

page changes once a swipe lands, and position changes during it. Use page for text and anything that re-renders, and position for anything that should move with the finger.

Without composing

<Bracket> takes renderPagination for the common case of replacing just the tabs. It receives the same state as usePagination() and is only called while the bracket is paged:

<Bracket
  stage={stage}
  renderPagination={({ rounds, page, setPage }) => (
    <View style={{ flexDirection: 'row', justifyContent: 'center', gap: 8, paddingTop: 12 }}>
      {rounds.map((round, index) => (
        <Pressable key={round.id} onPress={() => setPage(index)}>
          <Text style={{ fontWeight: index === page ? '700' : '400' }}>{round.name}</Text>
        </Pressable>
      ))}
    </View>
  )}
/>

A function you pass this way is a render function, not a component, so it can't call hooks itself. For a useAnimatedStyle indicator, render a component from it, as Dots does above.

Zoom controls

With mode="pan-zoom", the whole tree sits in a frame you can pinch, drag and double tap, with zoom buttons in its corner. Those buttons are parts too:

PartWhat it isCustomize
Bracket.ZoomControlsThe toolbar. Without children: zoom out, the zoom level and zoom in in one pill, and fit beside it. Draws nothing while the tree isn't panned and zoomed.style as a function of { canZoomIn, canZoomOut, canReset }, children
Bracket.ZoomIn, Bracket.ZoomOutZoom by zoom.step. Disabled at the largest and smallest zoom.style and children as functions of { disabled, pressed }
Bracket.ZoomResetBack to where the tree started. Disabled while it is already there.style and children as functions of { disabled, pressed }
Bracket.ZoomLevelThe zoom as a percentage. Updates as a pinch passes each whole percent.style as a function of { scale, percent }, children as a function of the same

Their accessibility labels come from labels.zoomIn, labels.zoomOut and labels.zoomReset, so a translated bracket translates them too.

Move them

Bracket.Viewport takes controls: true for the default toolbar in the bottom right corner, false for none, or your own element, drawn inside the frame. Whatever you pass is placed in the frame as it is, so give it an absolute position:

<Bracket.Root stage={stage} compactBelow={0}>
  <Bracket.Viewport
    mode="pan-zoom"
    controls={<Bracket.ZoomControls style={{ position: 'absolute', top: 10, left: 10 }} />}
  />
</Bracket.Root>

Your own buttons in the frame

Give Bracket.ZoomControls children to keep its toolbar role and draw your own buttons. Here they stack down the right side:

const round = ({ disabled, pressed }: { disabled: boolean; pressed: boolean }) => ({
  width: 40,
  height: 40,
  borderRadius: 20,
  alignItems: 'center' as const,
  justifyContent: 'center' as const,
  backgroundColor: pressed ? '#e4e4e7' : '#fff',
  opacity: disabled ? 0.4 : 1,
});

<Bracket.Viewport
  mode="pan-zoom"
  controls={
    <Bracket.ZoomControls style={{ position: 'absolute', top: 10, right: 10, flexDirection: 'column', gap: 8 }}>
      <Bracket.ZoomIn style={round}>
        <Text>+</Text>
      </Bracket.ZoomIn>
      <Bracket.ZoomOut style={round}>
        <Text>-</Text>
      </Bracket.ZoomOut>
      <Bracket.ZoomReset style={round}>
        <Text>⤢</Text>
      </Bracket.ZoomReset>
    </Bracket.ZoomControls>
  }
/>;

A toolbar outside the frame

useZoom() works anywhere inside Bracket.Root and returns null while the tree isn't panned and zoomed, for example while a phone shows one round at a time. Turn the frame's controls off and put your own anywhere:

import { Bracket, useZoom } from '@react-brackets/native';
import { Pressable, Text, View } from 'react-native';

function ZoomBar() {
  const zoom = useZoom();
  if (!zoom) return null;
  const buttons = [
    { label: 'Zoom out', onPress: () => zoom.zoomOut(), disabled: !zoom.canZoomOut },
    { label: 'Zoom in', onPress: () => zoom.zoomIn(), disabled: !zoom.canZoomIn },
    { label: 'Largest', onPress: () => zoom.zoomTo(zoom.maxScale), disabled: !zoom.canZoomIn },
    { label: 'Fit', onPress: zoom.reset, disabled: !zoom.canReset },
  ];
  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 12 }}>
      <Bracket.ZoomLevel>
        {({ percent }) => <Text style={{ width: 52, fontWeight: '700', fontVariant: ['tabular-nums'] }}>{percent}%</Text>}
      </Bracket.ZoomLevel>
      {buttons.map(({ label, onPress, disabled }) => (
        <Pressable
          key={label}
          accessibilityRole="button"
          accessibilityState={{ disabled }}
          disabled={disabled}
          onPress={onPress}
          style={{ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, borderWidth: 1, opacity: disabled ? 0.4 : 1 }}
        >
          <Text>{label}</Text>
        </Pressable>
      ))}
    </View>
  );
}

<Bracket.Root stage={stage} compactBelow={0} zoom={{ maxScale: 3 }}>
  <ZoomBar />
  <Bracket.Viewport mode="pan-zoom" controls={false} />
</Bracket.Root>;
From useZoom()What it is
scaleThe zoom now, as a shared value. Read it in worklets and animated styles.
fitScale, minScale, maxScaleThe zoom that shows the whole width, and the limits.
canZoomIn, canZoomOut, canResetWhether each action would do anything. They change the moment a limit is reached or left, even during a pinch.
zoomIn(step?), zoomOut(step?)Zoom on the middle by step, or by zoom.step.
zoomTo(scale, focus?)Zoom to a scale, keeping the point focus (in the frame, in points) still.
panBy(dx, dy), panTo(x, y)Move the tree, kept within its edges.
reset()Back to where the tree started.

scale is a shared value so that something drawn from it can follow a pinch frame by frame. A plain number would only change once the pinch ends. For text, Bracket.ZoomLevel already turns it into a percentage for you.

Without composing

<Bracket> takes renderZoomControls, which replaces the toolbar in the frame. It receives the useZoom() state, and what it returns is placed in the frame, so position it:

<Bracket
  stage={stage}
  compactBelow={0}
  mode="pan-zoom"
  renderZoomControls={(zoom) => (
    <Pressable onPress={zoom.reset} style={{ position: 'absolute', right: 10, bottom: 10 }}>
      <Text>Fit</Text>
    </Pressable>
  )}
/>

How it feels

Every number behind swiping, panning and zooming is an option, on <Bracket> or Bracket.Root. Leave them out and you get what the bracket has always done.

zoom

For the whole tree in pan-zoom mode.

OptionDefaultWhat it does
minScale'fit'The smallest zoom. 'fit' shows the whole width, and is never more than 1.
maxScale2The largest zoom. maxScale on <Bracket> is a shorthand for this.
step1.5How much the buttons, zoomIn() and zoomOut() zoom at a time.
initial'fit'Where the tree starts, and where reset takes it: 'fit', a scale, or { scale, x, y }.
align'start'Where a tree smaller than the frame sits: free from the start, or held in the middle with 'center'.
pinch, pantrueTurn pinching or dragging off.
doubleTap'toggle''toggle' zooms in twice over and back out to fit. A number zooms in by that much. false turns it off.
momentumtrueWhether a drag carries on once you let go. { deceleration } sets how quickly it slows, from 0 to 1, 0.998 by default.
elasticity0.6How far a fling runs past an edge before springing back. 0 stops hard at the edges.
overshoot0.15How far a pinch may go past the smallest and largest zoom before settling back.
animationmotion{ duration, easing } for zoom animations: the buttons, double tap and reset. Off for people who prefer reduced motion.
onZoomChangeCalled with the scale once a zoom lands.
// Hard edges, no glide, no double tap
<Bracket stage={stage} mode="pan-zoom" zoom={{ elasticity: 0, momentum: false, doubleTap: false }} />

// Start at full size, centred while it's smaller than the frame
<Bracket stage={stage} mode="pan-zoom" zoom={{ initial: 1, align: 'center' }} />

// A longer, looser glide, and zoom up to four times
<Bracket stage={stage} mode="pan-zoom" zoom={{ momentum: { deceleration: 0.999 }, maxScale: 4 }} />

initial as { scale, x, y } places the tree's top left corner at x, y in the frame, in points, at that scale. Negative values move the tree left and up, to start on a later round. The position is kept within the tree's edges.

swipe

For the paged view, a round at a time.

OptionDefaultWhat it does
focus0.76The share of the width given to the round in focus. The rest shows the next round coming.
commit0.2How far, as a share of a round, a slow drag has to go to move to the next round.
projection0.18How far ahead a flick is projected, in seconds of its speed, to judge where it was heading. Higher values let a quick flick travel further.
elasticity0.55How much a drag past the first or last round gives. 0 stops hard.
spring{ mass: 1, stiffness: 260, damping: 30 }The spring a released swipe settles with.
// Each round fills the width, and a swipe never runs past the ends
<Bracket stage={stage} swipe={{ focus: 1, elasticity: 0 }} />

// A bouncier landing
<Bracket stage={stage} swipe={{ spring: { stiffness: 180, damping: 14 } }} />

maxSwipeRounds limits how many rounds one swipe can move, and pageHeight sets how the height moves between rounds of different lengths. Both are covered in Layouts.

On this page