Nukodi
Nukodi

Reputation: 357

How to create a custom radio button?

I have been looking online to create custom check boxes and radio buttons. I have managed to create a checkbox but an issue I am having with the radio box is that clicking on it does not activate or trigger the onChange call on my input. I currently have this:

const customButtons = props => {
 const [isChecked, setChecked] = React.useState(false);

 const toggleCheck = e => {
  setChecked(e.target.checked || !isChecked)
 }

 return (
  <>
   <span className={container}>
    <input type={props.type} checked={isChecked} onChange={e => toggleCheck(e)} id={props.id} />
    <span></span>
   </span>
  </>
 ) 
}

I have used css to get the span to cover the radio button and made the original radio button display: none; but when I click on the span circle it does not trigger the click. I added an onClick to the span: <span onClick={toggleCheck}> but this causes the radio button to be unchecked when clicked twice. What is a better way to implement the custom radio button while maintaining the original behavior?

I am also using scss if that matters.

Upvotes: 2

Views: 12436

Answers (2)

iamcastelli
iamcastelli

Reputation: 1774

Your approach works for both radio and checkboxes if the inputs are not set to display: none, just like normal inputs of course. But if you set them to display: none, you are essentially hiding them from UI events so they won't trigger any click at all.


TLDR: Better approach would be, set the opacity: 0 on the input, use a label with htmlFor to trigger the change. Then style the label pseudo elements to look like radios.

Here is a link to a Live Code Sandbox here


Since you did not provide the styles, it's hard to tell how you visually laid out your custom inputs. With my approach,

  • Most UIs use radios when only one option is required for selection and checkboxes for multiple choices. That said, it gets easy to lift the state from the individual radio options to the parent radio group component, then pass down the radio state, while have the checkboxes control their individual state since they are built to be independent of each other.

  • Another observation is that your radios lack the name attribute(Reason why you were seeing multiple clicks with just fewer or no change at all) making them disjoint from each other. To place them in a group, they need to share a common name attr, that way you only target just the option value for each radio.

  • Once all the radio options without a common group(no name attribute) are selected, you cannot unselect them on the UI, so they wont trigger any further onChange events. For this reason, its also advisable to add a reset option to clear the options if they are not mandatory.

Here is the code for each the Radio Input component.

const RadioInput = ({ name, label, value, isChecked, handleChange }) => {
  const handleRadioChange = e => {
    const { id } = e.currentTarget;
    handleChange(id); // Send back id to radio group for comparison
  };

  return (
    <div>
      {/* Target this input: opacity 0 */}
      <input
        type="radio"
        className="custom-radio"
        name={name}
        id={value} // htlmlFor targets this id.
        checked={isChecked}
        onChange={handleRadioChange}
      />
      <label htmlFor={value}>
        <span>{label}</span>
      </label>
    </div>
  );
};

See, usually when writing custom inputs to override the native ones, it's easier if you target the label element and utilize its for a.k.a htmlFor attribute to select the input. From previous hustles, it's hard to please all screen readers with custom elements, especially when the native input you override is set to display none.

In my opinion, it's better to just position it absolutely, set its opacity to zero and let the label trigger it's on change.

Link to Sandbox here


The full Code for the components

App.js



import React, { useState } from "react";
import "./styles.scss";

/*
Let Checkbox the controls its own state.
Styling 'custom-radio', but only make the borders square in .scss file.
*/
const CheckboxInput = ({ name, label }) => {
  const [isChecked, setIsChecked] = useState(false);

  const toggleCheck = e => {
    setIsChecked(() => !isChecked);
  };

  return (
    <div>
      <input
        type="checkbox"
        className="custom-radio"
        name={name}
        id={name}
        checked={isChecked}
        onChange={toggleCheck}
      />
      <label htmlFor={name}>
        <span>{label}</span>
      </label>
    </div>
  );
};

/*
The custom radio input, uses the same styles like the checkbox, and relies on the 
radio group parent for its state.
*/
const RadioInput = ({ name, label, value, isChecked, handleChange }) => {
  const handleRadioChange = e => {
    const { id } = e.currentTarget;
    handleChange(id);
  };

  return (
    <div>
      <input
        type="radio"
        className="custom-radio"
        name={name}
        id={value}
        checked={isChecked}
        onChange={handleRadioChange}
      />
      <label htmlFor={value}>
        <span>{label}</span>
      </label>
    </div>
  );
};

/*
This is what control the radio options. Each radio input has the same name attribute
that way you can have multiple groups on the form.
*/
const RadioGropupInput = () => {
  const [selectedInput, setSelectedInput] = useState("");

  const handleChange = inputValue => {
    setSelectedInput(inputValue);
  };

  return (
    <>
      <div>
        {/*
          You could map these values instead from an array of options
          And an option to clear the selections if they are not mandatory.
          PS: Add aria attributes for accessibility
        */}
        <RadioInput
          name="option"
          value="option-1"
          label="First Choice"
          isChecked={selectedInput === "option-1"}
          handleChange={handleChange}
        />
        <RadioInput
          name="option"
          value="option-2"
          label="Second Choice"
          isChecked={selectedInput === "option-2"}
          handleChange={handleChange}
        />
        <RadioInput
          name="option"
          value="option-3"
          label="Third Choice"
          isChecked={selectedInput === "option-3"}
          handleChange={handleChange}
        />
      </div>
    </>
  );
};

export default () => (
  <div className="App">
    <RadioGropupInput />
    <hr />
    <CheckboxInput name="remember-me" label="Remember Me" />
    <CheckboxInput name="subscribe" label="Subscribe" />
  </div>
);


The Styles



.custom-radio {
  /* Hide the input element and target the next label that comes after it in the DOM */
  position: absolute;
  display: inline-block;
  opacity: 0;

  & + label {
    cursor: pointer;
    display: inline-block;
    position: relative;
    white-space: nowrap;
    line-height: 1rem;
    margin: 0 0 1.5rem 0;
    padding: 0 0 0 1rem;
    transition: all 0.5s ease-in-out;

    span {
      margin-left: 0.5rem;
    }

    /* Styles these pseudo elements to look like radio inputs. */
    &::before,
    &::after {
      content: '';
      position: absolute;
      color: #f5f5f5;
      text-align: center;
      border-radius: 0;
      top: 0;
      left: 0;
      width: 1rem;
      height: 1rem;
      transition: all 0.5s ease-in-out;
    }

    &::before {
      text-rendering: auto;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      line-height: 1rem;
      border-radius: 0;
      background-color: #ffffff;
      color: #ffffff;
      box-shadow: inset 0 0 0 1px #666565, inset 0 0 0 1rem #ffffff,
        inset 0 0 0 1rem #6b0707;
    }

    &:hover,
    &:focus,
    &:active {
      color: red;
      font-weight: bolder;
      transition: all 0.3s ease;
      outline: none;

      &::before {
        color: #ffffff;
        animation-duration: 0.5s;
        animation-name: changeSizeAnim;
        animation-iteration-count: infinite;
        animation-direction: alternate;
        box-shadow: inset 0 0 0 1px #6b0707, inset 0 0 0 16px #ffffff,
          inset 0 0 0 16px #6b0707;
      }
    }
  }

  &:focus,
  &:hover,
  &:checked {
    & + label {
      color: #220000 !important;
    }

    & + label::before {
      animation-duration: 0.3s;
      animation-name: selectCheckboxAnim;
      animation-iteration-count: 1;
      animation-direction: alternate;
      border: solid 1px rgba(255, 0, 0, 0.5);
      box-shadow: inset 0 0 0 1px #bc88d4, inset 0 0 0 0 #ffffff,
        inset 0 0 1px 2px #6d1717;
    }
  }

  &:checked {
    & + label::before {
      content: '✔'; /* Swap out this emoji checkmark with maybe an icon font of base svg*/
      background-color: #d43333;
      color: #ffffff;
      border: solid 1px rgba(202, 50, 230, 0.5);
      box-shadow: inset 0 0 0 1px #bc88d4, inset 0 0 0 0 #ffffff,
        inset 0 0 0 16px #d43333;
    }
  }

  & + label {
    &::before {
      border-radius: 50%;
    }
  }

  &[type=checkbox] {
    & + label {
      &::before {
        /* Remove the border radius on the checkboxes for a square effect */
        border-radius: 0;
      }
    }
  }


  @keyframes changeSizeAnim {
    from {
      box-shadow: 0 0 0 0 #d43333,
        inset 0 0 0 1px #d43333,
        inset 0 0 0 16px #FFFFFF,
        inset 0 0 0 16px #d43333;
    }

    to {
      box-shadow: 0 0 0 1px #d43333,
        inset 0 0 0 1px #d43333,
        inset 0 0 0 16px #FFFFFF,
        inset 0 0 0 16px #d43333;
    }
  }

  /* Add some animations like a boss, cause why would you hustle to build
  a custom component when you can't touch this!
  */
  @keyframes selectCheckboxAnim {
    0% {
      box-shadow: 0 0 0 0 #bc88d4,
        inset 0 0 0 2px #FFFFFF,
        inset 0 0 0 3px #d43333,
        inset 0 0 0 16px #FFFFFF,
        inset 0 0 0 16px #d43333;
    }

    100% {
      box-shadow: 0 0 20px 8px #eeddee,
        inset 0 0 0 0 white,
        inset 0 0 0 1px #bc88d4,
        inset 0 0 0 0 #FFFFFF,
        inset 0 0 0 16px #d43333;
    }
  }
}

/* Styles used to test out and reproduce out your approach */
.container.control-experiment {
  background: #fee;


  span,
  input {
    display: flex;

    border: solid 1px red;
    width: 2rem;
    height: 2rem;
    line-height: 2rem;
    display: inline-block;
  }

  input {    
    position: absolute;
    margin: 0;
    padding: 0;
  }

  input[type='radio'] {
    // display: none; /* Uncommenting this out makes all your inputs unsable.*/
  }
}

I repeat for emphasis, don't forget to add in the aria attributes for the custom input. Again you can test out the live Sandbox

Upvotes: 5

Khabir
Khabir

Reputation: 5854

I tried your example and used log in toggleCheck and it is triggered for both radio and checkbox.

CustomButtons Component

import React from "react";

const CustomButtons = props => {
    const [isChecked, setChecked] = React.useState(false);

    const toggleCheck = e => {
        console.log(e);
        setChecked(e.target.checked || !isChecked)
    };

    return (
        <>
           <span>
            <input type={props.type} checked={isChecked} onChange={e => toggleCheck(e)} id={props.id}/>
            <span>{props.text}</span>
           </span>
        </>
    )
};

export default CustomButtons

How to use CustomButtons in App.js

<CustomButtons type={"radio"} text={"One"}/>
<CustomButtons type={"radio"} text={"Two"}/>

<CustomButtons type={"checkbox"} text={"One"}/>
<CustomButtons type={"checkbox"} text={"Two"}/>

Upvotes: 1

Related Questions