# Hangline — 3D Clothing Store Product Carousel for React, with Sizes, Bag & Accessory Tray

> An endless 3D clothing rail for fashion stores in React: garments hang in true perspective, the piece in front faces you, and sizes, add to bag, a detail view and an accessory tray sit beside it. CSS 3D, no 3D library, light and dark.

- Kind: Component (Carousels)
- Price: Free
- License: MIT License. Do anything with it, keeping the copyright notice.
- Developer: UpFork
- Tags: carousel, hero, gallery, ecommerce, fashion, clothing-store, product-carousel, infinite-carousel, css-3d, shopping-bag, nextjs
- Published: 2026-11-14
- Updated: 2026-11-14
- Page: https://upfork.dev/products/clothing-store-product-carousel-react

## How to get it

Install it into a React + Tailwind project with the shadcn CLI. It's free for everyone, so it needs no account, token or setup:

```bash
npx shadcn@latest add https://upfork.dev/r/clothing-store-product-carousel-react.json
```

A project set up with UpFork's registry and the member's token (UPFORK_TOKEN) installs it as `@upfork/clothing-store-product-carousel-react` too.

## About

A clothing rail for fashion stores, as a React component in CSS 3D. Garments hang on one steel rail in true perspective. The piece in front faces you on a face-out arm, and the rest wait along the rail, edge on, with their own shadows on the wall. Step, drag or flick, and the rail travels: pieces swing a little on their hangers and settle. Choose a colour and a size, and add to the bag, beside the rail or in the piece's own view. An oak tray of accessories sits on the same wall: the room pans across to it.

It is for a small edit, 8 to 20 pieces. It is not a catalogue grid.

## Install

Copy `registry/hangline/` (15 files) into your project and put your pictures under `public/`. The component imports its own stylesheet, so your bundler must take CSS imports (Next.js and Vite do). React 19 is the only dependency. There is no CSS framework and no 3D library: the room is DOM and CSS transforms, and the pictures are plain `<img>` elements.

```tsx
import Hangline from "./hangline/hangline";

<Hangline
  garments={garments}
  accessories={accessories}
  wall={{ src: "/hangline/room/wall-relief.webp" }}
  tray={{ src: "/hangline/room/tray.webp" }}
  syncUrl
  onAddToBag={addToCart}
  onCheckout={goToCheckout}
/>;
```

It has been run in a Next.js 16 app (server-rendered, StrictMode) and in a plain Vite app (StrictMode). Give it a box with a height: the whole window, or a section. On a phone it lays itself out as a page, so let it grow there (`height: auto` on its parents under 700 px, as the demo's `registry/ellund/ellund.css` does).

**The demo store** is in `registry/demos/` (`default` is the full store, then a section in a page and a small rail of eight) and `registry/ellund/` (its data, page styles and fonts). Read it as an example; it is not part of the component.

**Fonts:** the component loads none. It reads `--hl-display` (piece names, default Instrument Serif, then Georgia) and `--hl-text` (everything else, default the system font). Set them on `:root`. The demo loads Instrument Serif and Hanken Grotesk.

**Names:** every class and custom property starts with `hl-` or `--hl-`.

## What your pictures must be

This is the part that matters. **The rail needs a straight-on photograph of each garment on a hanger, cut out, with its hook point**, because the component draws one hook for every hanger and hangs the picture from it.

- A transparent PNG or WebP, the garment facing the camera on its hanger, with the same hanger, light and camera height across the set. Pictures from different shoots look pasted together on one rail.
- `hook`: where the hanger's neck meets the drawn hook, 0 to 1 of the picture.
- `size`: the piece's height on the rail against a typical jacket (1 is about 70% of the scene's height).
- `shadow.near` and `shadow.far`: the picture's alpha, blurred, for the wall shadows.
- `outline`: the garment's silhouette as the points of a CSS `polygon()`. Only that shape answers the pointer, so a piece waiting beside the front one can be pointed at. It is optional: without it the whole picture answers.

`scripts/photos/` is the pipeline that made the demo's set: cut-out (shadows and shaded gaps kept out, each edge cleaned against the backdrop actually beside it), hook point, colourways registered onto one another so a colour change is a clean crossfade, backs colour-matched to their fronts, sizes in WebP and AVIF, the shadow masks and the outline. `audit_edges.py` scores every cut-out's edge and writes 300% crops of it on each ground it meets. It needs Python 3 with Pillow, NumPy, OpenCV and SciPy. Read it and adapt it to your own pictures: it is a working tool, not a product. `PHOTOS.md` shows how to run it.

`fromShopifyProducts(products, { photo, swatch, gallery })` maps Shopify products to `Garment` objects. It is a plain mapper and makes no network calls. Be realistic about it: **a shop's own product photos are usually not straight-on hanger cut-outs**, and on those the rail will not look right until the pictures are redone. You pass it a `photo` function that returns the prepared hanger picture for a product in a colour.

## The demo's pictures are not covered by MIT

The code is MIT. **The pictures are not.** They keep their own licences or terms, and each one has a row in `PHOTOS.md`.

- **Every garment and accessory picture is AI-generated**, made by us with AI image tools: 58 of the 76 source files name xAI (Grok), and the rest carry an unnamed C2PA mark or nothing (`PHOTOS.md` lists each file's marks). We have not verified those tools' terms for this use: check them yourself, or replace the pictures. Nothing in this product says they are real photographs.
- **The demo rail has 14 garments (one of them in two colours) and the tray 6 accessories, each with its own pictures:** a front on its hanger, the back, a worn view and a close-up, or for an accessory a view from above and a close-up.
- The wall (plaster) and the tray (oak) are real scans from Poly Haven, CC0.

Replace the pictures with your own before you sell anything. Ellund, its piece names and its prices are invented, and nothing in the demo is for sale.

## Props

| Prop | What it does |
|---|---|
| `garments` | The rail. Each has `id`, `slug`, `href`, `name`, `price`, `currency` and `colourways` (each with a `name`, a `swatch`, the hanger `photo`, its `gallery` and its `sizes` with stock); optional `compareAtPrice`, `description`, `material`, `care`, `sizeChart`, `wearWith` |
| `accessories` | The tray. Each has a `photo` from above, a `place` on the tray (`x`, `y`, `rotation`), a `gallery`, and optional `variants`. Left out, there is no tray |
| `wall`, `tray` | The wall's relief and the tray's surface: `{ src }` |
| `theme` | `"auto"` (default: your `dark` class, then the system), `"light"` or `"dark"` |
| `accent` | Focus rings, the chosen piece on the tray and the added state's check. Default the stylesheet's own (`--hl-accent`) |
| `wheel` | `"page"` (default): a vertical wheel over the rail scrolls the page. `"rail"`: it moves the rail |
| `loop` | `true` (default): an endless rail. Next after the last piece is the first, a piece that leaves at the front rejoins the rail behind the last one, and the queue is always full. `false`: the rail stops at its first and last pieces. Needs three pieces or more |
| `openSlug`, `defaultOpenSlug`, `onOpenChange` | Which piece is open, controlled or not |
| `syncUrl` | Keeps the open piece in the address (`true` for `?piece=`, or `{ param }`), with back and deep links |
| `category`, `onCategoryChange` | Clothing or accessories, controlled or not |
| `storeName`, `heading`, `eyebrow` | The top bar's name, and the words beside the rail |
| `detailTop` | Your site's header height, so an open piece is brought fully into view below it |
| `bag`, `onBagChange` | Control the bag from outside. Left out, it keeps its own |
| `onAddToBag` | May return a promise. A rejected one shows that it failed and keeps the choice |
| `onCheckout` | The bag's checkout button |
| `saved`, `onSavedChange` | The heart on each piece |
| `labels`, `formatPrice`, `dir` | Every word, every price, and right to left |
| `barExtra` | Your own control in the top bar (the demo's pull-cord for the light) |

Also exported: `SizeTiles`, `SizeGuide`, `AddToBagPill`, `BagDrawer`, `fromShopifyProducts`.

## Behaviour

- **Moving:** the arrows, the arrow keys (Home and End for the first and the last piece), a drag, a flick (at most three pieces), or a click on a waiting piece. Steps chain into one move, and a new command continues from where the rail is, at its speed. The rail is endless: after the last piece comes the first, and the counter runs 14 / 14, 1 / 14. A piece that leaves at the front rejoins the rail behind the last one, out of the far end's depth, so it never appears from nothing.
- **Opening:** "See the piece", or Enter on the front piece. The rest of the rail steps aside, and the piece's view opens beside it (a card from the bottom on a phone). Esc, Back, or the browser's back closes it, and focus returns to the piece.
- **Phones:** a vertical swipe on the scene scrolls the page and a sideways one moves the rail. The add-to-bag button stays on screen, and every target is at least 44 px.
- **Reduced motion:** a step is a short dip through the wall's tone, with no travel and no swing.
- **Without JavaScript:** a plain list of the pieces, with their pictures, names, prices and links.
- **Right to left:** pass `dir="rtl"` and your `labels`; the room mirrors.

## Checked, with numbers

On a production build in Chrome, on a notebook with Intel Iris Plus graphics, with all 14 pieces' own pictures, while other work ran on the same machine:

- Component JavaScript: 21.6 KB gzipped without React; the stylesheet 8.1 KB.
- First view at 1440 × 900: 853 KB of pictures (the budget is 900). The queue loads small pictures, and a piece takes its full size on its way to the front. The rest of the endless rail follows once the page has loaded on a wide screen (200 KB), and on a phone only when the rail first moves (about 790 KB at 3x, which a visitor who only looks never loads).
- LCP about 1.0 s on fast 4G, at 1440 and on a phone at full speed.
- No layout shift. Steps, colours and sizes answer in 24 to 104 ms. No animation frames while idle.

**Not met:** with the phone's CPU slowed four times, LCP is 3.0 to 3.6 s. One choice of an accessory in three passes had one long frame. The room is laid out in the browser, after hydration.

## Checked

Every interaction in both themes, 14 window sizes from 320 to 3440 px, touch, right to left, reduced motion, the keyboard and axe; frame-by-frame filmstrips of the rail, the endless rail across its seam included; no text moving by layout during a move; contrast on the real pixels behind each text; server rendering in StrictMode with deep links; the page without JavaScript; and a plain Vite app. The checks themselves (Playwright scripts) are not part of this package.

## Licence

MIT for the code, © UpFork (upfork.dev). The pictures are not covered: see `PHOTOS.md` and `LICENSE`.
