Changing absolute element position with getBoundingClientRect()

121 views Asked by At

I created a small reproduction of my code with which i have an issue. I wanted to create a functionality of small square element (.box) sliding to the center of selected answer. Currently its sliding wrong as its going completely different direction than it should. I assume i would need to use commented line // const boxRect = box.getBoundingClientRect(); and play with its position but tried several times without success.

The important thing is that the first slide must start from .box initial position defined in css. Then it should just change its position between selected answers.

const { useRef } = React;

const ANSWERS = ["answer 1", "answer 2", "answer 3", "answer 4"];

const App = () => {
   const boxRef = useRef(null);

  const handleSelectAnswer = (answer) => {
    const selectedAnswerElement = document.querySelector(
      `[data-answer="${answer}"]`
    );
    const box = boxRef.current;
    const answersContainer = document.querySelector(".answers");

    if (selectedAnswerElement && box && answersContainer) {
      const selectedAnswerRect = selectedAnswerElement.getBoundingClientRect();
      const containerRect = answersContainer.getBoundingClientRect();
      // const boxRect = box.getBoundingClientRect();

      const offsetX =
        selectedAnswerRect.left -
        containerRect.left +
        selectedAnswerRect.width / 2;

      const offsetY =
        selectedAnswerRect.top -
        containerRect.top +
        selectedAnswerRect.height / 2;

      box.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
    }
  };

  return (
    <div className="answers">
      {ANSWERS.map((answer) => (
        <button
          key={answer}
          data-answer={answer}
          onClick={() => handleSelectAnswer(answer)}
        >
          {answer}
        </button>
      ))}

      <div className="box" ref={boxRef} />
    </div>
  
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
.answers {
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(2, 270px);
  grid-template-rows: repeat(2, 1fr);
  column-gap: 1rem;
  row-gap: 1rem;
  color: white;
  position: relative;
  width: max-content;
}

.answers button {
  height: 150px;
}

.box {
  width: 50px;
  height: 50px;
  background-color: lightblue;
  position: absolute;
  bottom: -25%;
  left: 0;
  right: 0;
  margin: 0 auto;
  transition: all 0.5s ease;
  z-index: 999;
}
<div id="root">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js"></script>

</div>

3

There are 3 answers

2
Mike 'Pomax' Kamermans On BEST ANSWER

You can move the box on top of an answer by moving its center to "on top of the answer's center", e.g.

const { width: bw, height: bh } = box.getBoundingClientRect();
const { top, left, width, height } = selectedAnswerElement.getBoundingClientRect();
const newBoxTop = top + height/2 - bh/2 + window.scrollY);
const newBoxLeft = left + width/2 - bw/2 + window.scrollX);

(Remembering to take scroll x/y into account)

And while you can compute the position using JS, you probably want to use CSS to handle the rest by setting CSS variables rather than messing with element.style properties directly, because you want your CSS to live in your CSS files, not in your CSS and your JS. Define your CSS rules in terms of CSS variables, and then make JS update those variables only:

const { useRef, useEffect, useState } = React;

const ANSWERS = ["answer 1", "answer 2", "answer 3", "answer 4"];

const App = () => {
  const boxRef = useRef(null);
  
  // Switch the box to use absolute positioning once it's on the page.
  useEffect(() => {
    const box = boxRef.current;
    const { top, left } = box.getBoundingClientRect();
    box.classList.add(`ready`);
    box.style.setProperty(`--top`, top);
    box.style.setProperty(`--left`, left);
  }, []);

  // then move its center to a clicked answer's box's center
  const handleSelectAnswer = (answer) => {
    const box = boxRef.current;
    const { width: bw, height: bh } = box.getBoundingClientRect();

    const selectedAnswerElement = document.querySelector(`.answers [data-answer="${answer}"]`);
    const { top, left, width, height } = selectedAnswerElement.getBoundingClientRect();

    // move the centers, remembering to take h/vscroll into account:
    box.style.setProperty(`--top`, top + height/2 - bh/2 + window.scrollY);
    box.style.setProperty(`--left`, left + width/2 - bw/2 + window.scrollX);
  };

  return (
    <React.Fragment>
      <div className="answers">
        {ANSWERS.map((answer) => (
          <button key={answer} data-answer={answer} onClick={() => handleSelectAnswer(answer)}>
            {answer}
          </button>
        ))}
        <div className="box" ref={boxRef} />
      </div>
    </React.Fragment>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
.answers {
  display: grid;
  grid-template-columns: repeat(2, 270px);
  grid-template-rows: 1fr 1fr auto;
  grid-template-areas:
    "a a"
    "a a"
    "box box";
  column-gap: 1rem;
  row-gap: 1rem;
  width: max-content;
  margin: 0 auto;
  color: white;
}

.answers button {
  height: 150px;
}

.box {
  grid-area: box;
  width: 50px;
  height: 50px;
  margin: 0 auto;
  background-color: lightblue;
}

.box.ready {
  --top: 0;
  --left: 0;
  position: absolute;
  top: calc(var(--top) * 1px);
  left: calc(var(--left) * 1px);
  transition: all 0.5s ease;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>

2
Bharti Sharma On

I have removed default left, right and bottom css from your box elements so it will not cause any issue in dynamic position property in js and Also I have used state to store the x and y position of box element. Please check updated below code.

const { useRef, useState, useEffect } = React;

const ANSWERS = ["answer 1", "answer 2", "answer 3", "answer 4"];

const App = () => {
   const boxRef = useRef(null);
     const [boxPosition, setBoxPosition] = useState({ x: 0, y: 0 });


  const handleSelectAnswer = (answer) => {
    const selectedAnswerElement = document.querySelector(
      `[data-answer="${answer}"]`
    );
    const box = boxRef.current;
    const answersContainer = document.querySelector(".answers");

    if (selectedAnswerElement && box && answersContainer) {
      const selectedAnswerRect = selectedAnswerElement.getBoundingClientRect();
      const containerRect = answersContainer.getBoundingClientRect();
      const boxRect = box.getBoundingClientRect();

       const offsetX = selectedAnswerRect.left - containerRect.left;
      const offsetY = selectedAnswerRect.top - containerRect.top;

      const boxOffsetX = offsetX + selectedAnswerRect.width / 2 - boxRect.width / 2;
      const boxOffsetY = offsetY + selectedAnswerRect.height / 2 - boxRect.height / 2;
      
      setBoxPosition({ x: boxOffsetX, y: boxOffsetY });
    }
  };
  
   useEffect(() => {
    handleSelectAnswer(ANSWERS[0])
  },[])

  return (
    <div className="answers">
      {ANSWERS.map((answer) => (
        <button
          key={answer}
          data-answer={answer}
          onClick={() => handleSelectAnswer(answer)}
        >
          {answer}
        </button>
      ))}

      <div className="box" ref={boxRef} style={{ transform: `translate(${boxPosition.x}px, ${boxPosition.y}px)` }} />
    </div>
  
  );
};

ReactDOM.render(<App />, document.getElementById("root"));
.answers {
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(2, 270px);
  grid-template-rows: repeat(2, 1fr);
  column-gap: 1rem;
  row-gap: 1rem;
  color: white;
  position: relative;
  width: max-content;
}

.answers button {
  height: 150px;
}

.box {
  width: 50px;
  height: 50px;
  background-color: lightblue;
  position: absolute;
  margin: 0 auto;
  transition: all 0.5s ease;
  z-index: 999;
}
<div id="root">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js"></script>

</div>

Let me know if it works for you or not.

2
UHpi On

Extending to your approach I have just added one more reference variable to store the initial coordinates of the box. And now I am calculating the transform offsets taking those initial coordinates as reference.

Apart from the functionality you mention I have also added that if the user clicks on the already selected answer, then the box slides back to its initial position.

const { useRef, useEffect } = React;

const ANSWERS = ["answer 1", "answer 2", "answer 3", "answer 4"]

const App = () => {
  const boxRef = useRef(null)
  const initialPositionOfBoxRef = useRef(null)

  useEffect(() => {
    const rect = boxRef.current.getBoundingClientRect()
    const answersContainer = document.querySelector(".answers")
    const containerRect = answersContainer.getBoundingClientRect()
    initialPositionOfBoxRef.current = { x: Math.floor(rect.left - containerRect.left + rect.width / 2), y: Math.floor(rect.top - containerRect.top + rect.height / 2) }
  }, [])

  const handleSelectAnswer = (answer) => {
    const selectedAnswerElement = document.querySelector(
      `[data-answer="${answer}"]`
    )
    const box = boxRef.current
    const answersContainer = document.querySelector(".answers")

    if (selectedAnswerElement && box && answersContainer) {
      const selectedAnswerRect = selectedAnswerElement.getBoundingClientRect()
const containerRect = answersContainer.getBoundingClientRect()
      const boxRect = box.getBoundingClientRect()

      const selectedAnswerRectCenter = { x: Math.floor(selectedAnswerRect.left - containerRect.left + selectedAnswerRect.width / 2), y: Math.floor(selectedAnswerRect.top - containerRect.top + selectedAnswerRect.height / 2) }
        const boxRectCenter = { x: Math.floor(boxRect.left - containerRect.left + boxRect.width / 2), y: Math.floor(boxRect.top - containerRect.top + boxRect.height / 2) }

      const offsetX = selectedAnswerRectCenter.x - initialPositionOfBoxRef.current.x
        const offsetY = selectedAnswerRectCenter.y - initialPositionOfBoxRef.current.y

      if (selectedAnswerRectCenter.x === boxRectCenter.x && selectedAnswerRectCenter.y === boxRectCenter.y) {
        box.style.transform = `translate(${0}px, ${0}px)`
      } else {
        box.style.transform = `translate(${offsetX}px, ${offsetY}px)`
      }
    }
  }

  return (
    <div className="answers">
      {ANSWERS.map((answer) => (
        <button
          key={answer}
          data-answer={answer}
          onClick={() => handleSelectAnswer(answer)}
        >
          {answer}
        </button>
      ))}

      <div className="box" ref={boxRef} />
    </div>

  )
}

ReactDOM.render(<App />, document.getElementById("root"));
.answers {
  margin: 0 auto;
  display: grid;
  grid-template-columns: repeat(2, 270px);
  grid-template-rows: repeat(2, 1fr);
  column-gap: 1rem;
  row-gap: 1rem;
  color: white;
  position: relative;
  width: max-content;
}

.answers button {
  height: 150px;
}

.box {
  width: 50px;
  height: 50px;
  background-color: lightblue;
  position: absolute;
  bottom: -25%;
  left: 0;
  right: 0;
  margin: 0 auto;
  transition: all 0.5s ease;
  z-index: 999;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>