Skip to content
txbrendan/ui

Position Slot

beta

One player in one position on a team sheet: their photo, their name, then "10 · Fly-half", with pick-then-place moves.

Preview

states

Noah Kerr10 · Fly-half
Not picked6 · Blindside flanker
Josh Ellis4 · Injured
Owen Hughes12 · Not available
Ravi Patel22 · Replacement

moving

team sheet: click a player, then click where they go. A mouse can drag them too.

Nobody picked up.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/position-slot

Use it for

  • Team sheets and depth charts: one slot per shirt number.
  • Name positions with positionOf(format, number), for 15s, sevens, 10s, and U13. It says "Replacement" past the starters.
  • Move players by pick, then place: a click on a player picks them up (held), then pass holding to every other slot. A click there swaps or places them.
  • Mouse drags work too, through onPick and onDrop. Keep the moves in your own state either way.
  • Set a player's status to injured or unavailable. It shows in place of the position name.
  • Put the player's avatar in player.avatar.

Avoid it for

  • Drag as the only way to move someone. Always give onClick too.
  • A plain list of people. Use a list or a table.
  • Boxing each slot in a card. A slot has no box, so a sheet of them breathes.

Props

  • number

    The shirt number, which says where they play.

    number

  • position

    The position's name, like "Fly-half". positionOf("15s", 10) gives it.

    string

  • player

    Who plays here. null or left out when the position is empty.

    { person: string; avatar?: ReactNode; status?: "injured" | "unavailable" } | null

  • holding

    Someone picked up in another slot. This slot shows a dashed edge, and its hint says "Swap with …" or "Put … here".

    PositionSlotPlayer | null

  • held

    This slot's player is the one picked up.

    boolean · default false

  • onClick

    Picks up this player, or places the held one here. Without it the slot is not a button.

    () => void

  • onPick

    A mouse drag started here.

    () => void

  • onDrop

    Something was dropped here.

    () => void

  • disabled

    Blocks clicks and drags.

    boolean · default false

  • className

    Extra classes. Later classes win.

    string

Accessibility

  • With onClick it is a native button, so Enter and Space pick up and place, as a click does.
  • A held slot has aria-pressed="true".
  • The hint, like "Swap with Noah Kerr", stays in the button's name at all times, so screen readers hear what a press does.
  • Injured and Not available are words, not only colors. They use the danger and warning soft text, which the theme holds to 4.5:1 on the page and cards.
  • Photos are hidden from screen readers, since the name is right beside them.
  • positionOf and positions come from a client file. Call them in client code, or pass position names from the server.

Examples

A read-only slot

<PositionSlot number={10} position="Fly-half" player={{ person: "Noah Kerr" }} />

Names from the format

"use client";

import { PositionSlot, positionOf } from "@/components/ui/position-slot";

export function FrontRow() {
  return [1, 2, 3].map((number) => (
    <PositionSlot key={number} number={number} position={positionOf("15s", number)} />
  ));
}

Pick, then place

"use client";

import { useState } from "react";
import { PositionSlot, positionOf, type PositionSlotPlayer } from "@/components/ui/position-slot";

export function Backs() {
  const [sheet, setSheet] = useState<Record<number, PositionSlotPlayer | null>>({
    9: { person: "Sam Carter" },
    10: { person: "Noah Kerr" },
    12: null,
  });
  const [held, setHeld] = useState<number | null>(null);

  const press = (number: number) => {
    if (held === null) {
      if (sheet[number]) setHeld(number);
      return;
    }
    if (held !== number) setSheet({ ...sheet, [number]: sheet[held], [held]: sheet[number] });
    setHeld(null);
  };

  return Object.keys(sheet).map(Number).map((number) => (
    <PositionSlot
      key={number}
      number={number}
      position={positionOf("15s", number)}
      player={sheet[number]}
      held={held === number}
      holding={held !== null && held !== number ? sheet[held] : null}
      onClick={() => press(number)}
    />
  ));
}