FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Avatar

Avatar

pnpm dlx shadcn@latest add …/r/avatar.json

An accessible React avatar for Next.js and Tailwind CSS: a picture or initials in three sizes, alone or in an overlapping group, built on Radix Avatar.

  • OMJL
    Small
  • OMJL
    Medium
  • OMJL
    Large
  • OMJLINWK+3
    Group
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"

export function UserAvatar({ user }) {
  return (
    <Avatar>
      <AvatarImage src={user.image} alt={user.name} />
      <AvatarFallback>{user.initials}</AvatarFallback>
    </Avatar>
  )
}
sizes.tsx
import {
  Avatar,
  AvatarFallback,
  AvatarGroup,
  AvatarGroupCount,
  AvatarImage,
} from "@/components/ui/avatar";

/*
 * Pictures drawn as small grey SVGs, so the demo works offline. Use your people's photo URLs instead: when one is
 * missing or can't load, the initials stay, named by the alt.
 */
const picture = (shapes: string) =>
  `data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" fill="white"/>${shapes}</svg>`,
  )}`;

const people = [
  {
    name: "Olivia Martin",
    initials: "OM",
    picture: picture(
      '<rect width="40" height="40" fill-opacity=".7"/><circle cx="26" cy="14" r="6" fill="white" fill-opacity=".75"/><path d="M0 30q11-8 21-3t19-2v15H0z" fill-opacity=".45"/>',
    ),
  },
  { name: "Jackson Lee", initials: "JL" },
  {
    name: "Isabella Nguyen",
    initials: "IN",
    picture: picture(
      '<rect width="40" height="40" fill-opacity=".35"/><path d="M0 34 13 16l8 10 6-7 13 15v6H0z" fill-opacity=".55"/><path d="M0 40v-4l13-9 9 6 6-4 12 7v4z" fill-opacity=".35"/>',
    ),
  },
  { name: "William Kim", initials: "WK" },
];

const sizes = [
  { size: "sm", label: "Small" },
  { size: "md", label: "Medium" },
  { size: "lg", label: "Large" },
] as const;

/* Each size with a picture and with initials, then a group with a count, each named underneath */
export default function AvatarSizes() {
  return (
    <ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
      {sizes.map(({ size, label }) => (
        <li key={size} className="flex flex-col items-center gap-2">
          <div className="flex items-center gap-3">
            {people.slice(0, 2).map((person) => (
              <Avatar key={person.name} size={size}>
                <AvatarImage src={person.picture} alt={person.name} />
                <AvatarFallback>{person.initials}</AvatarFallback>
              </Avatar>
            ))}
          </div>
          <span className="text-xs text-muted-foreground">{label}</span>
        </li>
      ))}
      <li className="flex flex-col items-center gap-2">
        <AvatarGroup aria-label="Team, 7 people">
          {people.map((person) => (
            <Avatar key={person.name}>
              <AvatarImage src={person.picture} alt={person.name} />
              <AvatarFallback>{person.initials}</AvatarFallback>
            </Avatar>
          ))}
          <AvatarGroupCount>+3</AvatarGroupCount>
        </AvatarGroup>
        <span className="text-xs text-muted-foreground">Group</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/avatar.tsx
    "use client";
    
    import { blur, duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import { animate, useReducedMotion } from "motion/react";
    import { Avatar as AvatarPrimitive } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      type Ref,
      type RefObject,
      useCallback,
      useContext,
      useEffect,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
      useSyncExternalStore,
    } from "react";
    
    /*
     * An avatar on Radix Avatar (owner's roadmap, 2026-10-16: "A picture or initials, alone or in a group"), with
     * shadcn's parts: Avatar, AvatarImage, AvatarFallback, AvatarGroup and AvatarGroupCount ("+3"), in three sizes.
     * - Motion (.agents/rules/motion.md): once the picture has loaded, it comes in through the blur over the initials,
     *   which stay underneath until it's sharp, so nothing pops or jumps. With reduced motion it only fades.
     * - Nothing animates on first paint: the picture comes in only when the avatar was on screen without it first
     *   (rendered on the server, or still loading). A picture that's ready on the avatar's first paint just shows.
     * - The initials speak for the picture: while they stand in, they carry its alt as their name, so a screen reader
     *   hears "Olivia Martin" whether or not the picture has loaded. With alt="" (a name beside it) they're hidden too.
     * - Radix mounts the img only once it has loaded, and drops its own fallback that moment. So the fallback here is
     *   our own span, which the picture tells when it's fully in.
     * - In a group, a ring in the page's background colour separates the overlapping circles. The overlap follows the
     *   size, and each covered avatar's initials centre in its visible part, so two letters are never cut.
     */
    
    /** Where the picture is: not on screen, coming in over the initials, or fully in */
    type Picture = "none" | "entering" | "shown";
    
    const AvatarContext = createContext<{
      picture: Picture;
      setPicture: (picture: Picture) => void;
      /** The picture's alt, which the initials carry while they stand in for it */
      alt: string | undefined;
      setAlt: (alt: string | undefined) => void;
      /** Rendered on the server, so the initials were painted before the picture could load */
      fromServer: boolean;
    } | null>(null);
    
    function useAvatar(part: string) {
      const avatar = useContext(AvatarContext);
      if (!avatar) throw new Error(`${part} must be used inside Avatar`);
      return avatar;
    }
    
    const subscribe = () => () => {};
    
    const avatarVariants = cva("group/avatar relative flex shrink-0 overflow-hidden rounded-full select-none", {
      variants: {
        size: {
          sm: "size-6",
          md: "size-8",
          lg: "size-10",
        },
      },
      defaultVariants: { size: "md" },
    });
    
    export type AvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & VariantProps<typeof avatarVariants>;
    
    function Avatar({ className, size, ...props }: AvatarProps) {
      // While hydrating, React reads the server value (false): the avatar knows its initials were painted first
      const hydrated = useSyncExternalStore(
        subscribe,
        () => true,
        () => false,
      );
      const [fromServer] = useState(!hydrated);
      const [picture, setPicture] = useState<Picture>("none");
      const [alt, setAlt] = useState<string>();
      const value = useMemo(() => ({ picture, setPicture, alt, setAlt, fromServer }), [picture, alt, fromServer]);
    
      return (
        <AvatarContext.Provider value={value}>
          <AvatarPrimitive.Root
            data-slot="avatar"
            data-size={size ?? "md"}
            className={cn(avatarVariants({ size }), className)}
            {...props}
          />
        </AvatarContext.Provider>
      );
    }
    
    /** Hands a node to our ref and to the caller's */
    function useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) {
      return useCallback(
        (node: T | null) => {
          own.current = node;
          if (typeof theirs === "function") theirs(node);
          else if (theirs) theirs.current = node;
        },
        [own, theirs],
      );
    }
    
    export type AvatarImageProps = ComponentProps<typeof AvatarPrimitive.Image>;
    
    /**
     * The picture. Radix loads it first and only then puts it on the page, so a broken or missing `src` leaves the
     * initials. Give it an `alt` with the person's name, or alt="" when the name is written beside the avatar.
     */
    function AvatarImage({ className, alt, ref, onLoadingStatusChange, ...props }: AvatarImageProps) {
      const { setPicture, setAlt, fromServer } = useAvatar("AvatarImage");
      const reduce = useReducedMotion() ?? false;
      const node = useRef<HTMLImageElement | null>(null);
      const setRef = useMergedRef(node, ref);
      const [loaded, setLoaded] = useState(false);
      // Whether the avatar has been on screen without its picture: then the picture comes in, rather than just
      // being there
      const waited = useRef(fromServer);
      // Read when the picture arrives, so a change of preference never replays it
      const reduceRef = useRef(reduce);
      useEffect(() => {
        reduceRef.current = reduce;
      }, [reduce]);
    
      useLayoutEffect(() => {
        setAlt(alt);
        return () => setAlt(undefined);
      }, [alt, setAlt]);
    
      // Radix reports "loaded" in the same commit that puts the img on the page, so this runs before it's painted
      useLayoutEffect(() => {
        const image = node.current;
        if (!loaded || !image) return;
        if (!waited.current) {
          setPicture("shown");
          return () => setPicture("none");
        }
        setPicture("entering");
        image.style.opacity = "0";
        let done = false;
        const controls = animate(
          image,
          reduceRef.current ? { opacity: [0, 1] } : { opacity: [0, 1], filter: [blur.hidden, blur.shown] },
          { duration: duration.base, ease: ease.out },
        );
        controls.then(() => {
          if (done) return;
          done = true;
          // No filter or inline opacity left behind (ADR 0011)
          image.style.removeProperty("filter");
          image.style.removeProperty("opacity");
          setPicture("shown");
        });
        return () => {
          done = true;
          controls.stop();
          setPicture("none");
        };
      }, [loaded, setPicture]);
    
      return (
        <AvatarPrimitive.Image
          data-slot="avatar-image"
          ref={setRef}
          alt={alt}
          onLoadingStatusChange={(status) => {
            // Loading and failing both leave the initials on screen for at least a frame
            if (status === "loading" || status === "error") waited.current = true;
            setLoaded(status === "loaded");
            onLoadingStatusChange?.(status);
          }}
          className={cn("absolute inset-0 size-full object-cover", className)}
          {...props}
        />
      );
    }
    
    export type AvatarFallbackProps = ComponentProps<"span"> & {
      /** Wait this long before showing the initials, so a picture that loads quickly never flashes them */
      delayMs?: number;
    };
    
    /** Initials (or an icon) shown until the picture has loaded, and instead of it when it can't */
    function AvatarFallback({ className, delayMs, ...props }: AvatarFallbackProps) {
      const { picture, alt } = useAvatar("AvatarFallback");
      const [ready, setReady] = useState(delayMs === undefined);
    
      useEffect(() => {
        if (delayMs === undefined) return;
        const timer = window.setTimeout(() => setReady(true), delayMs);
        return () => window.clearTimeout(timer);
      }, [delayMs]);
    
      if (!ready || picture === "shown") return null;
      // Standing in, it reads as the picture would. While the picture comes in over it, it's only seen.
      const standingIn = picture === "none";
      const name = standingIn && alt ? { role: "img", "aria-label": alt } : {};
      return (
        <span
          data-slot="avatar-fallback"
          aria-hidden={!standingIn || alt === "" ? true : undefined}
          {...name}
          className={cn(
            "flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground",
            "group-data-[size=sm]/avatar:text-xs group-data-[size=lg]/avatar:text-base",
            // In a group the next circle covers this one's end edge (its overlap plus its ring), so the initials centre
            // in the part that stays visible and are never cut
            "in-data-[slot=avatar-group]:group-not-last/avatar:pe-2",
            "group-data-[size=sm]/avatar:in-data-[slot=avatar-group]:group-not-last/avatar:pe-1",
            "group-data-[size=lg]/avatar:in-data-[slot=avatar-group]:group-not-last/avatar:pe-2.5",
            className,
          )}
          {...props}
        />
      );
    }
    
    /**
     * Avatars that overlap, each ringed in the page's background colour. A group (role="group"): name it with
     * aria-label, for example "Team".
     */
    function AvatarGroup({ className, ...props }: ComponentProps<"div">) {
      return (
        // biome-ignore lint/a11y/useSemanticElements: a group of pictures, not form fields, so not a fieldset
        <div
          role="group"
          data-slot="avatar-group"
          className={cn(
            // The overlap follows the size (2, 6 or 8px), small enough that two initials still fit beside it
            "group/avatar-group flex -space-x-1.5 has-data-[size=sm]:-space-x-0.5 has-data-[size=lg]:-space-x-2",
            "*:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
            className,
          )}
          {...props}
        />
      );
    }
    
    /** The last circle in a group, for the people not shown ("+3"). It takes the size of the avatars beside it. */
    function AvatarGroupCount({ className, ...props }: ComponentProps<"span">) {
      return (
        <span
          data-slot="avatar-group-count"
          className={cn(
            "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground ring-2 ring-background",
            "group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=sm]/avatar-group:text-xs",
            "group-has-data-[size=lg]/avatar-group:size-10",
            className,
          )}
          {...props}
        />
      );
    }
    
    export { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
  5. 5

    Update the import paths to match your project.

Examples

With a name

With the name written beside it, give the picture alt="": the avatar is hidden from screen readers and the name is read once.

  • SD
    Sofia Davissofia@example.com
  • JL
    Jackson Leejackson@example.com
  • WK
    William Kimwill@example.com
with-name.tsx
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

type Member = { name: string; email: string; initials: string; picture?: string };

// No pictures yet, so the initials stand in
const members: Member[] = [
  { name: "Sofia Davis", email: "sofia@example.com", initials: "SD" },
  { name: "Jackson Lee", email: "jackson@example.com", initials: "JL" },
  { name: "William Kim", email: "will@example.com", initials: "WK" },
];

/*
 * With the name written beside it, the avatar is decoration: alt="" hides the picture and the initials from
 * screen readers, so the name is read once.
 */
export default function AvatarWithName() {
  return (
    <ul className="grid w-full max-w-xs gap-4">
      {members.map((member) => (
        <li key={member.email} className="flex items-center gap-3">
          <Avatar size="lg">
            <AvatarImage src={member.picture} alt="" />
            <AvatarFallback>{member.initials}</AvatarFallback>
          </Avatar>
          <div className="grid min-w-0">
            <span className="truncate text-sm font-medium">{member.name}</span>
            <span className="truncate text-sm text-muted-foreground">{member.email}</span>
          </div>
        </li>
      ))}
    </ul>
  );
}

API reference

Avatar

The circle. Also accepts every Radix Avatar Root prop.

PropTypeDefaultDescription
size?"sm" | "md" | "lg""md"24, 32 or 40px. A group's count follows the avatars beside it.

AvatarImage

The picture. It appears once it has loaded, coming in through the blur when the initials were on screen first; a missing or broken src leaves the initials. Also accepts every native img prop.

PropTypeDefaultDescription
src?string–The picture's URL.
alt?string–The person's name. The initials carry it while they stand in. Use alt="" when the name is written beside the avatar.
onLoadingStatusChange?(status: "idle" | "loading" | "loaded" | "error") => void–Called as the picture loads, or fails to.

AvatarFallback

Initials or an icon, shown until the picture is fully in, and instead of it when it can't load. Also accepts every native span prop.

PropTypeDefaultDescription
delayMs?number–Wait this long before showing the initials, so a quick picture never flashes them.

AvatarGroup

Overlapping avatars, each ringed in the page's background colour. The overlap follows their size, and initials centre in the part left visible. A group (role="group"): name it with aria-label.

PropTypeDefaultDescription

AvatarGroupCount

The last circle in a group, for the people not shown ("+3"), at the size of its neighbours.

PropTypeDefaultDescription

Related components

  • Stablev2.4.0Beta
    Badge

    An accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.

  • Team plan
    8 of 10 seats used
    Active
    Card

    A React card for Next.js and Tailwind CSS: a surface for grouped content with header, action, content and footer parts, a small size and a link style.

    • v1.2.0-beta.20
    • v1.2.0-beta.19
    • v1.2.0-beta.18
    • v1.2.0-beta.17
    • v1.2.0-beta.16
    • v1.2.0-beta.15
    • v1.2.0-beta.14
    • v1.2.0-beta.13
    • v1.2.0-beta.12
    • v1.2.0-beta.11
    • v1.2.0-beta.10
    • v1.2.0-beta.9
    • v1.2.0-beta.8
    • v1.2.0-beta.7
    • v1.2.0-beta.6
    • v1.2.0-beta.5
    • v1.2.0-beta.4
    • v1.2.0-beta.3
    • v1.2.0-beta.2
    • v1.2.0-beta.1
    Scroll Area

    An accessible React scroll area for Next.js and Tailwind CSS: native scrolling with a slim scrollbar and soft fades at the edges, built on Radix.