How To Make an Image Banner Component with React
Tags: react, ui, typescript
Published: 2023-07-21 05:28:09

✨ [**Watch on YouTube**](https://youtu.be/BcQ05BR5Pgw)  |  🐙 [GitHub](https://github.com/radzionc/reactkit/blob/main/pages/image-banner.tsx)  |  🎮 [Demo](https://reactkit.radzion.com/image-banner)

Let's create a reusable banner component that looks nice, is dismissible, and has a call to action. Since we are developers and not graphic designers, we will use an image for the visual part. Here's an example of how I display it in my app at [increaser.org] to prompt people to watch a YouTube video related to the page's topic.

![Image banner](https://cdn-images-1.medium.com/proxy/1*ie0QftpB5A7lqvKF0o3PzA.jpeg)

## How to Use the ImageBanner Component

The `ImageBannerComponent` accepts five properties:

- `onClose` - a function that will be called when the user clicks the close button
- `action` - usually a button that will be displayed at the bottom of the banner
- `title` - the title of the banner
- `image` - an image for the background, represented as a React element instead of an image path to ensure compatibility with different frameworks like NextJS or Gatsby
- `renderInteractiveArea` - a function that wraps the banner with an interactive element, such as an external link or a button that triggers an action in the app

```tsx
interface ImageBannerProps {
  onClose: () => void
  action: ReactNode
  title: ReactNode
  image: ReactNode
  renderInteractiveArea: (props: ComponentWithChildrenProps) => ReactNode
}
```

We can see how to use the banner by checking the `HabitsEducationBanner` component in Increaser.

```tsx
import { ExternalLink } from "router/Link/ExternalLink"
import { HABITS_EDUCATION_URL } from "shared/externalResources"
import { PersistentStorageKey } from "state/persistentStorage"
import { usePersistentStorageValue } from "state/usePersistentStorageValue"
import { ThemeProvider } from "styled-components"
import { Button } from "ui/Button/Button"
import { HSLA } from "ui/colors/HSLA"
import { YouTubeIcon } from "ui/icons/YouTubeIcon"
import { ImageBanner } from "ui/ImageBanner"
import { CoverImage } from "ui/images/CoverImage"
import { SafeImage } from "ui/images/SafeImage"
import { HStack } from "ui/Stack"
import { Text } from "ui/Text"
import { darkTheme } from "ui/theme/darkTheme"

const titleColor = new HSLA(220, 45, 30)

export const HabitsEducationBanner = () => {
  const [interactionDate, setInteractionDate] = usePersistentStorageValue<
    number | undefined
  >(PersistentStorageKey.HabitsEducationWasAt, undefined)

  const handleInteraction = () => {
    setInteractionDate(Date.now())
  }

  if (interactionDate) return null

  return (
    <ThemeProvider theme={darkTheme}>
      <ImageBanner
        onClose={handleInteraction}
        renderInteractiveArea={(props) => (
          <ExternalLink
            onClick={handleInteraction}
            to={HABITS_EDUCATION_URL}
            {...props}
          />
        )}
        action={
          <Button size="xl" kind="reversed" as="div">
            <HStack alignItems="center" gap={8}>
              <YouTubeIcon />
              <Text>Watch now</Text>
            </HStack>
          </Button>
        }
        title={
          <Text as="span" style={{ color: titleColor.toCssValue() }}>
            learn to build better habits
          </Text>
        }
        image={
          <SafeImage
            src="/images/mountains.webp"
            render={(props) => <CoverImage {...props} />}
          />
        }
      />
    </ThemeProvider>
  )
}
```

We store the interaction timestamp in the local storage. If the user has already closed the banner or clicked on it, we don't show it again. For more information on setting up solid local storage, please refer to [this article](https://radzion.com/blog/local-storage).

To avoid extra work, the banner will have the same appearance in both dark and light modes. We achieve this by wrapping it with a `ThemeProvider` and making the dark theme the default for the banner.

We render the banner inside the `ExternalLink` component, which is an anchor element. This will open the video in a new tab. To track interactions with the banner, we add an `onClick` handler that updates the interaction date.

Since the banner is wrapped with an anchor, we don't want the action to be an interactive clickable element. Therefore, we render it as a `div` element and exclude the `onClick` handler. To ensure proper contrast between the title and the image background, we have selected a custom title color. For more information on effectively managing colors with HSLA format, please refer to [this article](https://radzion.com/blog/hsla-color).

Finally, we pass an image, which is wrapped with the `SameImage` component. This component won't render anything if the image fails to load. I found the mountain image on Unsplash, but next time I might use AI generative tools like Midjourney.

```tsx
import { ReactNode } from "react"
import { useBoolean } from "lib/shared/hooks/useBoolean"

interface RenderParams {
  src: string
  onError: () => void
}

interface Props {
  src?: string
  fallback?: ReactNode
  render: (params: RenderParams) => void
}

export const SafeImage = ({ fallback = null, src, render }: Props) => {
  const [isFailedToLoad, { set: failedToLoad }] = useBoolean(false)

  return (
    <>
      {isFailedToLoad || !src
        ? fallback
        : render({ onError: failedToLoad, src })}
    </>
  )
}
```

The `CoverImage` component takes up the entire space and sets `object-fit` to `cover`:

```tsx
import styled from "styled-components"

export const CoverImage = styled.img`
  width: 100%;
  height: 100%;
  object-fit: cover;
`
```

## How to Create the ImageBanner Component

Now, let's look at the implementation of the `ImageBanner` component. Since we cannot render a button inside another button, we need to wrap the banner with a position-relative element and render the close button absolutely. We use an abstract component called `ActionInsideInteractiveElement` to achieve this:

```tsx
import { ReactNode } from "react"
import styled from "styled-components"

import { ElementSizeAware } from "./ElementSizeAware"
import { ElementSize } from "./hooks/useElementSize"

interface ActionInsideInteractiveElementRenderParams<
  T extends React.CSSProperties
> {
  actionSize: ElementSize
  actionPlacerStyles: T
}

interface ActionInsideInteractiveElementProps<T extends React.CSSProperties> {
  className?: string
  render: (params: ActionInsideInteractiveElementRenderParams<T>) => ReactNode
  action: ReactNode
  actionPlacerStyles: T
}

const ActionPlacer = styled.div`
  position: absolute;
`

const Container = styled.div`
  position: relative;
`

export function ActionInsideInteractiveElement<T extends React.CSSProperties>({
  render,
  action,
  actionPlacerStyles,
  className,
}: ActionInsideInteractiveElementProps<T>) {
  return (
    <Container className={className}>
      <ElementSizeAware
        render={({ setElement, size }) => (
          <>
            {size &&
              render({
                actionPlacerStyles,
                actionSize: size,
              })}
            <ActionPlacer ref={setElement} style={actionPlacerStyles}>
              {action}
            </ActionPlacer>
          </>
        )}
      />
    </Container>
  )
}
```

The close button has the background of a text element and contains a close icon. We position it 20px from the top and right of the banner.

```tsx
import { ReactNode } from "react"
import styled from "styled-components"

import { ActionInsideInteractiveElement } from "./ActionInsideInteractiveElement"
import { defaultTransitionCSS } from "./animations/transitions"
import { CloseIcon } from "./icons/CloseIcon"
import { Panel } from "./Panel/Panel"
import { Text } from "./Text"
import { getColor } from "./theme/getters"
import { centerContentCSS } from "./utils/centerContentCSS"
import { fullyCoverAbsolutely } from "./utils/fullyCoverAbsolutely"
import { getSameDimensionsCSS } from "./utils/getSameDimensionsCSS"
import { interactiveCSS } from "./utils/interactiveCSS"
import { ComponentWithChildrenProps } from "lib/shared/props"

interface ImageBannerProps {
  onClose: () => void
  action: ReactNode
  title: ReactNode
  image: ReactNode
  renderInteractiveArea: (props: ComponentWithChildrenProps) => ReactNode
}

const padding = "20px"

const ImagePosition = styled.div`
  ${fullyCoverAbsolutely}
  ${defaultTransitionCSS}
`

const PositionAction = styled.div`
  position: absolute;
  bottom: ${padding};
  right: ${padding};
  pointer-events: none;
  ${defaultTransitionCSS}
`

const Content = styled.div`
  ${fullyCoverAbsolutely}
  padding: ${padding};
`

const Container = styled(Panel)`
  position: relative;
  min-height: 320px;

  box-shadow: ${({ theme }) => theme.shadows.medium};

  :hover ${PositionAction} {
    transform: scale(1.06);
  }

  :hover ${ImagePosition} {
    transform: scale(1.06);
  }

  :hover ${Content} {
    background: ${getColor("mistExtra")};
  }
`

const Title = styled(Text)`
  text-transform: uppercase;
  line-height: 1;

  font-size: 40px;

  @media (width <= 800px) {
    font-size: 32px;
  }
`

const Close = styled.button`
  all: unset;

  ${interactiveCSS};

  background: ${getColor("text")};

  ${defaultTransitionCSS};

  color: ${getColor("background")};

  border-radius: 8px;
  ${centerContentCSS};
  ${getSameDimensionsCSS(40)};
  font-size: 20px;

  :hover {
    background: ${getColor("contrast")};
  }
`

export const ImageBanner = ({
  onClose,
  action,
  title,
  image,
  renderInteractiveArea,
}: ImageBannerProps) => {
  const content = (
    <Container>
      <ImagePosition>{image}</ImagePosition>
      <Content>
        <Title weight="extraBold" as="h2">
          {title}
        </Title>
      </Content>
      <PositionAction>{action}</PositionAction>
    </Container>
  )

  return (
    <ActionInsideInteractiveElement
      action={
        <Close title="Dismiss" onClick={onClose}>
          <CloseIcon />
        </Close>
      }
      actionPlacerStyles={{
        right: padding,
        top: padding,
      }}
      render={() => renderInteractiveArea({ children: content })}
    />
  )
}
```

We wrap the content with an interactive element based on the consumer's choice by calling `renderInteractiveArea` and passing the banner itself as a child. The banner has the same shape as a `Panel` component, with `position` set to `relative`, a defined `min-height`, and a box shadow to make it stand out better in light mode. On hover, we make the image zoom in and make the button bigger by using `transform: scale(1.06)`. To draw attention to the title and button, we blur the background of the content area with the `mistExtra` color. For more information on an effective color palette for both dark and light modes, please check out my other [article](https://radzion.com/blog/theme).

All children inside the `Container` are positioned absolutely. We make the image take up the entire space and then render the content, which includes the title and blurs the image that is positioned below the container on hover. Finally, we position the action button and set `pointer-event: none`, since the entire banner is already clickable.