Moodinary/UI

Scroll area

Listas acotadas dentro de un panel. Scrollbar fina, nunca doble scroll vertical.

Contenedor con altura o ancho fijo y scroll propio.

Instalación

shadcn
npx shadcn@latest add https://design.moodinary.com/r/scroll-area.json
import
import { ScrollArea, ScrollAreaItem } from "@moodinary/ui/components/scroll-area";

Ejemplos

Grilla equipo por semana

El uso real en Analíticas: tabla nativa con scope dentro de un ScrollArea horizontal, con la columna de equipos fija.

Equipo4 ago11 ago18 ago25 ago1 sep8 sep15 sep22 sep29 sep
Equipo Ventas3,83,63,9–4,14,33,73,53,9
Equipo Soporte3,12,93,23,43,02,73,13,33,6
4.º A4,04,23,93,8–4,14,44,03,9

– sin pulsos esa semana (una celda con color puede ser de un solo pulso)

Código
heatmap.tsx
import { ScrollArea } from "@moodinary/ui/components/scroll-area";

const weeks = [
	"4 ago",
	"11 ago",
	"18 ago",
	"25 ago",
	"1 sep",
	"8 sep",
	"15 sep",
	"22 sep",
	"29 sep",
];
const rows: [string, (number | null)[]][] = [
	["Equipo Ventas", [3.8, 3.6, 3.9, null, 4.1, 4.3, 3.7, 3.5, 3.9]],
	["Equipo Soporte", [3.1, 2.9, 3.2, 3.4, 3.0, 2.7, 3.1, 3.3, 3.6]],
	["4.º A", [4.0, 4.2, 3.9, 3.8, null, 4.1, 4.4, 4.0, 3.9]],
];

function level(avg: number) {
	return Math.min(5, Math.max(1, Math.round(avg)));
}

export default function ScrollAreaHeatmap() {
	return (
		<figure className="w-full min-w-0">
			<ScrollArea
				aria-label="Promedio por equipo y semana"
				className="block"
				orientation="horizontal"
			>
				<table className="w-full min-w-max border-separate border-spacing-1 text-xs">
					<thead>
						<tr>
							<th
								className="sticky left-0 z-10 bg-card pr-4 pb-3 text-left font-medium text-ink-3"
								scope="col"
							>
								Equipo
							</th>
							{weeks.map((week) => (
								<th
									className="whitespace-nowrap px-1 pb-3 text-center font-normal text-ink-3"
									key={week}
									scope="col"
								>
									{week}
								</th>
							))}
						</tr>
					</thead>
					<tbody>
						{rows.map(([name, cells]) => (
							<tr key={name}>
								<th
									className="sticky left-0 z-10 whitespace-nowrap bg-card py-2 pr-4 text-left font-medium"
									scope="row"
								>
									{name}
								</th>
								{cells.map((avg, index) => (
									<td className="p-1 text-center" key={weeks[index]}>
										{avg === null ? (
											<span
												className="inline-flex h-[30px] w-full min-w-10 items-center justify-center rounded-md bg-bg-soft text-ink-3"
												title={`${name}, semana ${weeks[index]}: sin pulsos`}
											>
												–
											</span>
										) : (
											<span
												className="inline-flex h-[30px] w-full min-w-10 items-center justify-center rounded-md font-medium text-ink/70 tabular-nums"
												style={{
													backgroundColor: `color-mix(in oklab, var(--m${level(avg)}) ${level(avg) === 3 ? 18 : 28}%, var(--card))`,
												}}
												title={`${name}, semana ${weeks[index]}: ${avg.toLocaleString("es-AR")}`}
											>
												{avg.toLocaleString("es-AR", {
													minimumFractionDigits: 1,
												})}
											</span>
										)}
									</td>
								))}
							</tr>
						))}
					</tbody>
				</table>
			</ScrollArea>
			<p className="mt-2.5 text-ink-3 text-xs">
				– sin pulsos esa semana (una celda con color puede ser de un solo pulso)
			</p>
		</figure>
	);
}

Vertical

Listas de actividad con alto fijo.

Actividad ilustrativa.

4.º A recibió un pulso1 h
Se creó una acción2 h
Equipo Ventas pasó a Bien3 h
Nuevo comentario moderado4 h
Informe de septiembre disponible5 h
3.º A volvió a Bien6 h
Se sumó un responsable7 h
Equipo Soporte recibió un pulso8 h
Se agregó un evento9 h
Código
vertical.tsx
import { MoodDot } from "@moodinary/ui/components/mood-dot";
import {
	ScrollArea,
	ScrollAreaItem,
} from "@moodinary/ui/components/scroll-area";

const activity = [
	"4.º A recibió un pulso",
	"Se creó una acción",
	"Equipo Ventas pasó a Bien",
	"Nuevo comentario moderado",
	"Informe de septiembre disponible",
	"3.º A volvió a Bien",
	"Se sumó un responsable",
	"Equipo Soporte recibió un pulso",
	"Se agregó un evento",
];

export default function ScrollAreaVertical() {
	return (
		<div className="w-full max-w-sm">
			<p className="mb-2 text-ink-3 text-xs">Actividad ilustrativa.</p>
			<ScrollArea aria-label="Actividad reciente">
				{activity.map((item, index) => (
					<ScrollAreaItem key={item}>
						<MoodDot level={index % 2 === 0 ? 3 : 4} />
						<span>{item}</span>
						<span className="ml-auto whitespace-nowrap text-ink-3 text-xs">
							{index + 1} h
						</span>
					</ScrollAreaItem>
				))}
			</ScrollArea>
		</div>
	);
}

Horizontal

orientation="horizontal" para cards en fila.

Equipos y climas ilustrativos.

1.º A
Neutral
2.º A
Neutral
3.º B
Neutral
4.º A
Neutral
Equipo Ventas
Neutral
Equipo Soporte
Neutral
Código
horizontal.tsx
import {
	Card,
	CardContent,
	CardHeader,
	CardTitle,
} from "@moodinary/ui/components/card";
import { MoodWord } from "@moodinary/ui/components/mood-dot";
import { ScrollArea } from "@moodinary/ui/components/scroll-area";

export default function ScrollAreaHorizontal() {
	return (
		<div className="w-full max-w-sm">
			<p className="mb-2 text-ink-3 text-xs">Equipos y climas ilustrativos.</p>
			<ScrollArea aria-label="Equipos" orientation="horizontal">
				{[
					"1.º A",
					"2.º A",
					"3.º B",
					"4.º A",
					"Equipo Ventas",
					"Equipo Soporte",
				].map((team) => (
					<Card className="w-[140px] shrink-0" key={team} size="sm">
						<CardHeader>
							<CardTitle className="text-sm">{team}</CardTitle>
						</CardHeader>
						<CardContent>
							<MoodWord level={3} />
						</CardContent>
					</Card>
				))}
			</ScrollArea>
		</div>
	);
}

Props

ScrollArea

PropTipoDefaultDescripción
orientation"vertical" | "horizontal""vertical"Dirección del scroll.
aria-labelstring-Nombre de la región.
classNamestring-Clases extra.

ScrollAreaItem

PropTipoDefaultDescripción
...propsComponentProps<"div">-Fila con estilos de lista.
classNamestring-Clases extra.

Cuándo sí y cuándo no

Hacé
  • Dale un nombre con aria-label.
  • Usalo cuando el alto es fijo.
  • Mostrá que hay más contenido.
Evitá
  • No anides áreas de scroll.
  • No lo uses para toda la página.
  • No escondas acciones importantes al final.

Accesibilidad

  • La región es focusable para scrollear con teclado.
  • Lleva un nombre accesible.