shadimage
dominik-ui
uipermission-selectorsource: ok · refreshed

Permission Selector

A composable segmented permission control with an animated sliding pill — read/write levels and deny/neutral/allow toggles.

npx shadcn add "https://dominikkoch.dev/r/permission-selector.json"
free

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/8/2026, 3:32:58 AM

components/ui/permission-selector.tsx
"use client";

import { domMax, LazyMotion, m } from "motion/react";
import {
  createContext,
  type ReactNode,
  useContext,
  useId,
  useState,
} from "react";
import { cn } from "@/lib/utils";

type PermissionTone = "neutral" | "success" | "danger" | "warning";

const SPRING = { type: "spring", bounce: 0.2, duration: 0.4 } as const;

const TONE_TEXT: Record<PermissionTone, string> = {
  neutral: "text-foreground",
  success: "text-emerald-600 dark:text-emerald-400",
  danger: "text-destructive",
  warning: "text-amber-600 dark:text-amber-500",
};

const TONE_PILL: Record<PermissionTone, string> = {
  neutral: "bg-background ring-border",
  success: "bg-emerald-500/10 ring-emerald-500/30",
  danger: "bg-destructive/10 ring-destructive/30",
  warning: "bg-amber-500/10 ring-amber-500/30",
};

interface PermissionRowContextValue {
  value: string | undefined;
  select: (value: string) => void;
  layoutId: string;
  disabled: boolean;
}

const PermissionRowContext = createContext<PermissionRowContextValue | null>(
  null
);

function usePermissionRow() {
  const context = useContext(PermissionRowContext);
  if (!context) {
    throw new Error("PermissionOption must be used within a PermissionRow");
  }
  return context;
}

interface PermissionSelectorProps {
  children: ReactNode;
  label?: string;
  className?: string;
}

function PermissionSelector({
  children,
  label = "Permissions",
  className,
}: PermissionSelectorProps) {
  return (
    <fieldset
      className={cn(
        "w-full divide-y overflow-hidden rounded-xl border bg-background",
        className
      )}
    >
      <legend className="sr-only">{label}</legend>
      {children}
    </fieldset>
  );
}

interface PermissionRowProps {
  children: ReactNode;
  label: ReactNode;
  description?: ReactNode;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  disabled?: boolean;
  className?: string;
}

function PermissionRow({
  children,
  label,
  description,
  value,
  defaultValue,
  onValueChange,
  disabled = false,
  className,
}: PermissionRowProps) {
  const layoutId = useId();
  const [internalValue, setInternalValue] = useState(defaultValue);
  const activeValue = value ?? internalValue;

  const select = (next: string) => {
    setInternalValue(next);
    onValueChange?.(next);
  };

  const context: PermissionRowContextValue = {
    value: activeValue,
    select,
    layoutId,
    disabled,
  };

  return (
    <PermissionRowContext.Provider value={context}>
      <LazyMotion features={domMax}>
        <div
          className={cn(
            "flex items-center justify-between gap-4 px-4 py-3",
            className
          )}
        >
          <div className="flex min-w-0 flex-col gap-0.5">
            <span className="font-medium text-foreground text-sm">{label}</span>
            {description && (
              <span className="text-muted-foreground text-xs">
                {description}
              </span>
            )}
          </div>
          <div
            aria-label={typeof label === "string" ? label : undefined}
            className="flex shrink-0 items-center gap-0.5 rounded-lg border bg-muted/40 p-0.5"
            role="radiogroup"
          >
            {children}
          </div>
        </div>
      </LazyMotion>
    </PermissionRowContext.Provider>
  );
}

interface PermissionOptionProps {
  value: string;
  children: ReactNode;
  tone?: PermissionTone;
  disabled?: boolean;
  className?: string;
  "aria-label"?: string;
}

function PermissionOption({
  value,
  children,
  tone = "neutral",
  disabled: optionDisabled,
  className,
  "aria-label": ariaLabel,
}: PermissionOptionProps) {
  const {
    value: selected,
    select,
    layoutId,
    disabled: rowDisabled,
  } = usePermissionRow();
  const active = selected === value;
  const disabled = optionDisabled ?? rowDisabled;

  return (
    // biome-ignore lint/a11y/useSemanticElements: a single-select segmented control is the radiogroup/radio ARIA pattern; native radios can't hold custom pill content.
    <button
      aria-checked={active}
      aria-label={ariaLabel}
      className={cn(
        "relative flex min-w-7 cursor-pointer items-center justify-center rounded-md px-2.5 py-1 font-medium text-sm outline-none transition-colors duration-200 ease-out focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-4",
        active
          ? TONE_TEXT[tone]
          : "text-muted-foreground hover:text-foreground",
        className
      )}
      disabled={disabled}
      onClick={() => select(value)}
      role="radio"
      type="button"
    >
      {active && (
        <m.span
          className={cn(
            "absolute inset-0 rounded-md shadow-sm ring-1",
            TONE_PILL[tone]
          )}
          layoutId={layoutId}
          transition={SPRING}
        />
      )}
      <span className="relative z-10 flex items-center gap-1.5">
        {children}
      </span>
    </button>
  );
}

export { PermissionSelector, PermissionRow, PermissionOption };
export type {
  PermissionSelectorProps,
  PermissionRowProps,
  PermissionOptionProps,
  PermissionTone,
};

More from dominik-ui

Browse all components in this library →