⚠️ I'm actively rebuilding this site in Astro incrementally, and not waiting til I'm done! Something something work in public. See previous version.

Amberley Romo

Replacing File Input with an Image

I recently had occasion to build out a design for an in-place image update. When the user hovers or focuses the image, they should be able to select a file from their device storage, and replace the existing image with the newly selected image.

Final effect

The markup

The markup boils down to this:

<div className="upload">
  <img src="" alt="" className="upload__image" />
    Upload image
    accept="image/png, image/jpeg, image/gif, image/jpg"

First there’s a div, to help style the enclosed content. Then the image element. In this design it’s serving as both content and control. Next is the label and input element of type “file”.

The resulting (mostly unstyled) output amounts to:

Ultimately, we’ll end up visually hiding the input element and doing some positioning trickery for the image to act visually as both the content and upload button, and the label to act as the visual hover/focus state.

Hiding the input element

There are a handful of established ways to visually hide content. I tend to come back to this A11y Project post and grab the css for the “clip pattern”:

.visually-hidden {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;

Adding the .visually-hidden class to the input element will… visually hide it.

Styling the label

Because the label is programmatically associated with the (now hidden) input, clicking on the label text will still open a system dialog to select a local file. The input is still technically focusable as well, though obfuscated unless you’re using a screen reader. We’ll take advantage of the both of these facts.

We’ll style the label to take up the same amount of space as the image:

.upload__label {
  /* position absolute and fill space to cover image */
  position: absolute;
  top: 0;
  height: 100%;
  width: 100%;
  /* overlay will be opaque black, make text white */
  color: #fff;
  /* center text vertically and horizontally */
  display: flex;
  align-items: center;
  justify-content: center;
  /* indicate the area is clickable when hovered */
  cursor: pointer;
  /* temporary background to visually confirm element sized correctly */
  background-color: rgb(192, 19, 22);

But, we want the image to be visible until it’s hovered or focused:

.upload__label {
  /* ...everything from before */
  opacity: 0;

  &:hover {
    opacity: 100;

And finally, we’ll get rid of the red test background, and add our opaque black overlay using a :before pseudoelement:

.upload__label {
  /* ...everything from before */
  /* ...except the test background */
  /* background-color: rgb(192, 19, 22); */

  &:before {
    /* must be included to render pseudoelement */
    content: "";
    /* take up all available space and cover the content */
    position: absolute;
    height: 100%;
    width: 100%;
    /* create black opaque background */
    background-color: #000;
    opacity: 0.54;
    /* mirror the image border-radius */
    border-radius: 0.5rem;

One small problem left. The text appears to be below the black overlay. Turns out, pseudo-elements stack in front of their parent by default. We need to use z-index to position the pseudo-element behind, so that the black overlay sits behind the white text.

.upload__label {
  /* ... */
  /* create a new stacking context */
  z-index: 0;

  &:hover {
    /* ... */

  // use pseudoelement to create and style
  //the black opaque hover/focus overlay
  &:before {
    /* ... */
    /* send pseudoelement before parent in stacking order */
    z-index: -1;

Fixing focus styles

Now the label text and overlay show on hover, but right now there’s no visual indicator when the <input> element has focus. What we want is for the <label> styling to appear when the <input> has focus. To accomplish this we’ll use the adjacent sibling selector.

.upload__input:focus + .upload__label {
  opacity: 1;

So when the input has focus, the label (which is the immediate next element) will be styled with an opacity of 1.

This works, but creates one issue. After selecting a new file via hovering and clicking, the focus is maintained on the input, and therefore maintains its active styling. This seems visually confusing to me.

With a plain input of type file, if you click the “Choose file” button, and select a file, afterward there’s no visible focus.

If you navigate focus to the button with a keyboard, and select a file that way, afterward the <input> maintains visible focus.

We can solve this by using :focus-visible instead of :focus. The [:focus-visible selector](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) “is useful to provide a different focus indicator based on the user’s input modality (mouse vs. keyboard).”

.upload__input:focus-visible + .upload__label {
  opacity: 1;

Now when we click and select a file, the focus style isn’t maintained afterward:

If we navigate with the keyboard and select a file, the focus style is maintained:

Summing up

To summarize, now the <input> is visually hidden, and the <label> is styled to show 1) on hovering the <label>, or 2) on focusing the visually hidden <input>.

Further questions