Position Slot
betaOne player in one position on a team sheet: their photo, their name, then "10 · Fly-half", with pick-then-place moves.
Preview
states
moving
team sheet: click a player, then click where they go. A mouse can drag them too.
Nobody picked up.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. 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
| Prop | Type | Default | What it does |
|---|---|---|---|
| number | number | - | The shirt number, which says where they play. |
| position | string | - | The position's name, like "Fly-half". positionOf("15s", 10) gives it. |
| player | { person: string; avatar?: ReactNode; status?: "injured" | "unavailable" } | null | - | Who plays here. null or left out when the position is empty. |
| holding | PositionSlotPlayer | null | - | Someone picked up in another slot. This slot shows a dashed edge, and its hint says "Swap with …" or "Put … here". |
| held | boolean | false | This slot's player is the one picked up. |
| onClick | () => void | - | Picks up this player, or places the held one here. Without it the slot is not a button. |
| onPick | () => void | - | A mouse drag started here. |
| onDrop | () => void | - | Something was dropped here. |
| disabled | boolean | false | Blocks clicks and drags. |
| className | string | - | Extra classes. Later classes win. |
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)}
/>
));
}