FreeMIT LicenseNewFeatured

Turnrail — Fashion Shop Product Carousel & Detail Sheet for React Native and Expo

A clothing shop's rail for React Native and Expo: garments hang on a curved rail and turn to face you as they pass the front. Open one and it flies into a detail sheet with views, zoom, colours and sizes; add it and it flies into the bag. Light and dark.

by UpFork, built by Ramin ValadanMobile componentUpdated 14 Nov 2026

2 articles about itBehind the build38 lessons
Watch0
Preview of Turnrail — Fashion Shop Product Carousel & Detail Sheet for React Native and Expo

Code

21 files · 7 packages · 295 assets

import { PixelRatio, Pressable, StyleSheet, Text, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';

import { AddToBagPill, type PillState, SizeChips } from './controls';
import { nameSize, spaceKey, useDirection } from './direction';
import { cornerOf, labelOf, ring, useFace, useSkin } from './skin';
import type { Garment, TurnrailColours, TurnrailLabels } from './types';

export type CardProps = {
  garment: Garment;
  index: number;
  count: number;
  colourway: number;
  size: string | null;
  pill: PillState;
  pillReason?: string;
  onColourway: (i: number) => void;
  onSize: (label: string) => void;
  onAdd: () => void;
  onOpen: () => void;
  /** Counts presses of the pill with no size chosen: the chips light up briefly each time. */
  nudge?: number;
  /** Set for a few seconds after an add: "View bag" in the pill. */
  onViewBag?: () => void;
  onStep?: (delta: number) => void;
  colours: TurnrailColours;
  accent: string;
  labels: TurnrailLabels;
  formatPrice: (amount: number, currency: string) => string;
  fonts: { display?: string; text?: string };
  /** The card's width, for setting the name in two lines at most. */
  width: number;
  /** Short of room: 1 drops the description, 2 the swatch names too. */
  room?: 0 | 1 | 2;
  /** Small phones drop the description first, then the swatch names. */
  compact?: boolean;
  tight?: boolean;
};

export function Price({ g, colours, formatPrice, big }: { g: Garment; colours: TurnrailColours; formatPrice: CardProps['formatPrice']; fonts?: CardProps['fonts']; big?: boolean }) {
  const face = useFace();
  return (
    <View style={styles.priceRow}>
      <Text style={[big ? styles.priceBig : styles.price, face('600'), { color: colours.ink }]}>{formatPrice(g.price, g.currency)}</Text>
      {g.compareAtPrice ? (
        <Text style={[styles.was, face('400'), { color: colours.muted }]}>{formatPrice(g.compareAtPrice, g.currency)}</Text>
      ) : null}
    </View>
  );
}

export function Swatches({ g, colourway, onColourway, colours, accent, names }: { g: Garment; colourway: number; onColourway: (i: number) => void; colours: TurnrailColours; accent: string; fonts?: CardProps['fonts']; names: boolean }) {
  const skin = useSkin();
  const face = useFace();
  // A circle, or a square with the skin's corner: the ring sits 3 pt outside the swatch.
  const round = (d: number) => (skin.swatch === 'circle' ? d / 2 : Math.min(cornerOf(skin, d), 3));
  return (
    <View style={styles.swatches} accessibilityRole="radiogroup">
      {g.colourways.map((cw, i) => {
        const on = i === colourway;
        return (
          <Pressable key={cw.name} onPress={() => onColourway(i)} accessibilityRole="radio" accessibilityState={{ selected: on }} aria-checked={on} accessibilityLabel={cw.name} {...spaceKey(() => onColourway(i))}
            {...ring(colours.ink)} style={({ pressed }) => [styles.swatchHit, pressed && { opacity: 0.8 }]}>
            <View style={[styles.swatchRing, { borderColor: on ? accent : 'transparent', borderRadius: round(32) }]}>
              <View style={[styles.swatch, { backgroundColor: cw.swatch, borderColor: colours.line, borderRadius: round(24) }]} />
            </View>
            {names ? <Text style={[styles.swatchName, face(on ? '500' : '400'), { color: on ? colours.ink : colours.muted }]}>{cw.name}</Text> : null}
          </Pressable>
        );
      })}
    </View>
  );
}

function Chevron({ dir, colour }: { dir: -1 | 1; colour: string }) {
  // Right to left, "previous" sits on the right and points right.
  const flow = useDirection();
  return (
    <View style={flow < 0 ? styles.mirror : null}>
      <Svg width={18} height={18} viewBox="0 0 18 18">
        <Path d={dir < 0 ? 'M11 4 L6 9 L11 14' : 'M7 4 L12 9 L7 14'} stroke={colour} strokeWidth={1.8} fill="none" strokeLinecap="round" strokeLinejoin="round" />
      </Svg>
    </View>
  );
}

/** The counter with its chevrons: "4 / 14". */
export function Counter({ index, count, onStep, colours, labels }: { index: number; count: number; onStep?: (d: number) => void; colours: TurnrailColours; labels: TurnrailLabels; fonts?: CardProps['fonts'] }) {
  const skin = useSkin();
  const face = useFace();
  if (count <= 1) return null;
  const corner = cornerOf(skin, 44);
  return (
    <View style={styles.counter}>
      <Pressable onPress={() => onStep?.(-1)} disabled={index === 0} accessibilityRole="button" accessibilityLabel={labels.previous} hitSlop={4}
        {...ring(colours.ink)} style={({ pressed }) => [styles.chev, { borderRadius: corner }, pressed && { backgroundColor: colours.line }, index === 0 && { opacity: 0.35 }]}>
        <Chevron dir={-1} colour={colours.ink} />
      </Pressable>
      <Text style={[styles.count, face('500'), { color: colours.muted }]}>{labels.counter(index + 1, count)}</Text>
      <Pressable onPress={() => onStep?.(1)} disabled={index === count - 1} accessibilityRole="button" accessibilityLabel={labels.next} hitSlop={4}
        {...ring(colours.ink)} style={({ pressed }) => [styles.chev, { borderRadius: corner }, pressed && { backgroundColor: colours.line }, index === count - 1 && { opacity: 0.35 }]}>
        <Chevron dir={1} colour={colours.ink} />
      </Pressable>
    </View>
  );
}

/** Under the rail: the piece in front, and buying it right here. */
export function BuyingCard(p: CardProps) {
  const skin = useSkin();
  const face = useFace();
  const { garment: g, colours, labels, fonts, accent } = p;
  const cw = g.colourways[Math.min(p.colourway, g.colourways.length - 1)];
  const mark = g.badge === 'new' ? labels.newMark : g.badge === 'last-sizes' ? labels.lastSizes : null;
  const room = p.room ?? (p.tight ? 2 : 0);
  const base = p.tight ? 28 : 34;
  // Two lines at most, then smaller, never cut off (long names).
  const size = nameSize(g.name, Math.max(120, p.width - 40), base, PixelRatio.getFontScale());
  return (
    <View style={styles.card}>
      <View style={styles.headRow}>
        <Counter index={p.index} count={p.count} onStep={p.onStep} colours={colours} labels={labels} fonts={fonts} />
        <View style={styles.headRight}>
          {mark ? <Text style={[styles.mark, labelOf(skin, 13), face('600'), { color: accent, borderColor: accent, borderRadius: cornerOf(skin, 22) }]}>{mark}</Text> : null}
          <Pressable onPress={p.onOpen} accessibilityRole="button" accessibilityLabel={labels.seePiece} hitSlop={6}
            {...ring(colours.ink)} style={({ pressed }) => [styles.see, { borderRadius: cornerOf(skin, 44) }, pressed && { backgroundColor: colours.line }]}>
            <View>
              <Text style={[styles.seeText, labelOf(skin, 15), face('500'), { color: colours.ink }]}>{labels.seePiece}</Text>
              <View style={[styles.seeRule, { backgroundColor: colours.ink }]} />
            </View>
          </Pressable>
        </View>
      </View>
      <Text style={[styles.name, { fontSize: size, lineHeight: Math.round(size * 1.12), color: colours.ink, fontFamily: fonts.display }]} accessibilityRole="header">
        {g.name}
      </Text>
      <View style={styles.metaRow}>
        <Text style={[styles.kind, face('400'), { color: colours.muted }]}>{`${g.kind} · ${cw.name}`}</Text>
        <Price g={g} colours={colours} formatPrice={p.formatPrice} fonts={fonts} />
      </View>
      {g.description && room < 1 ? (
        <Text style={[styles.desc, face('400'), { color: colours.muted }]} numberOfLines={3}>{g.description}</Text>
      ) : null}
      {g.colourways.length > 1 ? (
        <Swatches g={g} colourway={p.colourway} onColourway={p.onColourway} colours={colours} accent={accent} fonts={fonts} names={room < 2} />
      ) : null}
      <SizeChips sizes={cw.sizes} value={p.size} onChange={p.onSize} colours={colours} accent={accent} labels={labels} compact={p.tight} nudge={p.nudge} />
      <View style={styles.actions}>
        <AddToBagPill state={p.pill} reason={p.pillReason} price={p.formatPrice(g.price, g.currency)} onPress={p.onAdd} onViewBag={p.onViewBag} colours={colours} accent={accent} labels={labels} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  card: { paddingHorizontal: 20, gap: 10 },
  headRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 44 },
  headRight: { flexDirection: 'row', alignItems: 'center', gap: 6, marginRight: -10 },
  counter: { flexDirection: 'row', alignItems: 'center', gap: 2, marginLeft: -12 },
  chev: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' },
  count: { fontSize: 15, fontVariant: ['tabular-nums'], minWidth: 52, textAlign: 'center' },
  mark: { fontSize: 13, fontWeight: '600', borderWidth: 1, paddingHorizontal: 9, paddingVertical: 2, overflow: 'hidden' },
  name: { fontSize: 34, lineHeight: 38, marginTop: -4 },
  mirror: { transform: [{ scaleX: -1 }] },
  metaRow: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginTop: -4 },
  kind: { fontSize: 15, flexShrink: 1 },
  desc: { fontSize: 15, lineHeight: 21 },
  priceRow: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
  price: { fontSize: 17, fontWeight: '600', fontVariant: ['tabular-nums'] },
  priceBig: { fontSize: 20, fontWeight: '600', fontVariant: ['tabular-nums'] },
  was: { fontSize: 15, textDecorationLine: 'line-through', fontVariant: ['tabular-nums'] },
  swatches: { flexDirection: 'row', gap: 14, flexWrap: 'wrap' },
  swatchHit: { flexDirection: 'row', alignItems: 'center', gap: 6, minHeight: 44 },
  swatchRing: { width: 32, height: 32, borderRadius: 16, borderWidth: 2, alignItems: 'center', justifyContent: 'center' },
  swatch: { width: 24, height: 24, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth },
  swatchName: { fontSize: 15 },
  actions: { gap: 6, marginTop: 2 },
  see: { minHeight: 44, paddingHorizontal: 10, justifyContent: 'center' },
  // The link's own rule, 1 pt under its words with room to breathe: not the browser's underline.
  seeRule: { height: 1, marginTop: 3 },
  seeText: { fontSize: 15, fontWeight: '500' },
});
21 files295 assets7 packages

From the workbench

Written about Turnrail — Fashion Shop Product Carousel & Detail Sheet for React Native and Expo

By the developer who built it: how it works, with its real code and live preview in the text, and the lessons learned along the way.

About

Turnrail is a clothing store's rail for your app: garments hang on hangers along a short, curved rail across the top of the screen. The piece in front faces you and can be bought right under the rail; its neighbours hang side-on, and the rest bunch at the brackets like hangers pushed aside.

  • A rail that moves like one. Drag it 1:1 from the first pixel, flick it, tap a piece or step with the arrows: pieces turn as they pass the front, the bunches give and take, and each piece swings a little as the rail stops. Steps chain into one move, and a hand on a moving rail catches it where it is.
  • Pieces at their real lengths. Give each garment its length in cm and a cropped jacket hangs shorter than a long coat, with soft shadows from one light.
  • Prices on swing tags. The piece in front wears its price on a small paper tag beside its hanger, with the old price struck through and "New" or "Last sizes". Point at any other piece, with a mouse or a resting finger, and its own price unfurls under its hook; on the web the rail knows each garment by its picture's outline, so even a sliver in a bunch shows its own price.
  • Up close, the same piece. Open one and it lifts off its hook into a sheet: front, side, back, worn and close-up views, double-tap or pinch to zoom, swipe down to close and it flies back onto its hook.
  • Buying, right there. Colours that swap the piece on the rail, sizes in your order with stock ("Medium, 2 left"), a size guide in cm or inches, and a pill that shows your request's progress and its error if it fails.
  • A bag that never opens by itself. An add flies into the bag button, which bumps and rolls its count. The bag has quantities up to the stock, five seconds to undo a removal, and a Checkout callback.
  • Everywhere. Phones from 320 pt wide, landscape, and tablets with a side panel beside a rail that stays live; as one section of a scrolling page with your own bag button; light and dark; right to left; reduced motion; screen readers and the keyboard.
  • Your catalogue. A plain data shape with pictures for 1x and 2x screens, front and side photos with a hook point each (the README says how to make them), and fromShopifyProducts for Storefront API products, which hang with a front-only turn.

Three demos: the whole store, the rail as one section of a scrolling page, and the rail for a shop with only front photos. The demo garments are AI-generated pictures; replace them with your own.

Reviews and bugs

0 reviews · 0 bugs

Ship it0With fixes0Skip0· 0 reviews

0 bugs0 open0 fixed

Members who have it review it and report its bugs. Sign in

No reviews or bugs yet.

Installation

React Native · Expo SDK 57

Built withExpo FontExpo HapticsGesture HandlerReanimatedSafe Area ContextSVG

  1. 01

    Copy the files into your app

    • components/turnrail/buying-card.tsx
    • components/turnrail/controls.tsx
    • components/turnrail/direction.ts
    • components/turnrail/gallery.tsx
    • components/turnrail/geometry.ts
    • components/turnrail/index.ts
    • components/turnrail/motion.ts
    • components/turnrail/outline.ts
    • components/turnrail/piece.tsx
    • components/turnrail/rail-view.tsx
    • components/turnrail/rail.tsx
    • components/turnrail/sheets.tsx
    • components/turnrail/shopify.ts
    • components/turnrail/skin.ts
    • components/turnrail/tags.tsx
    • components/turnrail/theme.ts
    • components/turnrail/turnrail.tsx
    • components/turnrail/types.ts
    • lib/demo-garments.ts
    • lib/use-demo-fonts.ts
    • lib/use-scheme.ts
    • demos/ (3 screens showing it in use)

    At your app’s root, the folders side by side as they are: the files import each other by relative paths. Its pictures and fonts come in Download source’s ZIP, where the code requires them.

  2. 02

    Install the packages with Expo

    npx expo install expo-font expo-haptics react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  3. 03

    See it in your app

    Make demos/store.tsx your app’s App.tsx, inside the GestureHandlerRootView that Gesture Handler needs at its root:

    import { GestureHandlerRootView } from 'react-native-gesture-handler';
    
    import Store from './demos/store';
    
    export default function App() {
      return (
        <GestureHandlerRootView style={{ flex: 1 }}>
          <Store scheme="light" />
        </GestureHandlerRootView>
      );
    }
    

UpFork installs every revision just so into a fresh Expo app (SDK 57), type-checks it and builds it for the web before it goes on sale. An AI assistant can do it for you through UpFork’s MCP server: Install mobile components has both ways.

Before you buy

Everything you need to know

How this mobile component gets from the preview into your project, and what buyers ask most.

How to use it

From the preview to your project

  1. 01

    Try it in the preview

    Switch between light and dark, and resize it to phone and tablet widths, before you decide.

  2. 02

    Get it

    Free products are yours with a free account. Paid ones settle in USDT on TRON, sent straight to the wallet on your order.

  3. 03

    Install it

    Components go into your project with one shadcn command, dependencies included. Everything else downloads with a checksum.

  4. 04

    Make it yours

    It's your code now: rename it, restyle it and wire in your real data.

  5. 05

    Or hand it to your AI

    Copy the prompt and let your coding assistant place it in your project, following your conventions.

Building with an AI assistant?

Copy a ready-made prompt with the code, its dependencies and a demo, and your assistant can put it in place for you.

Copy AI prompt

Selling your own work?

Get approved once, then publish components, templates and mobile components, paid in USDT straight to your own wallet.